12 Commits

17 changed files with 557 additions and 198 deletions

View File

@@ -14,9 +14,9 @@ export default async function sendOpenJobsEmails() {
const doctorAccounts = await getDoctorAccounts();
const doctorEmails = doctorAccounts
.map(({ email }) => email)
.filter((email) => !!email);
.filter((email): email is string => !!email);
if (doctorEmails !== null) {
if (doctorEmails.length === 0) {
return [];
}

View File

@@ -20,13 +20,14 @@ export const POST = async (request: NextRequest) => {
try {
const doctors = await sendOpenJobsEmails();
const doctorIds = doctors?.join(', ') ?? '-';
console.info(
'Successfully sent out open job notification emails to doctors',
`Successfully sent out open job notification emails to doctorIds: ${doctorIds}`,
);
await createNotificationLog({
action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'SUCCESS',
comment: `doctors that received email: ${doctors}`,
comment: `doctors that received email: ${doctorIds}`,
});
return NextResponse.json(
{
@@ -35,7 +36,7 @@ export const POST = async (request: NextRequest) => {
},
{ status: 200 },
);
} catch (e: any) {
} catch (e) {
console.error(
'Error sending out open job notification emails to doctors.',
e,
@@ -43,7 +44,7 @@ export const POST = async (request: NextRequest) => {
await createNotificationLog({
action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'FAIL',
comment: e?.message,
comment: e instanceof Error ? e.message : 'Unknown error',
});
return NextResponse.json(
{

View File

@@ -2,7 +2,6 @@
import React, { ReactElement, ReactNode, useMemo, useState } from 'react';
import { UserAnalysisElement } from '@/packages/features/accounts/src/types/accounts';
import { format } from 'date-fns';
import { Info } from 'lucide-react';
@@ -10,29 +9,24 @@ import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { AnalysisElement } from '~/lib/services/analysis-element.service';
import { NestedAnalysisElement } from '@/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema';
import AnalysisLevelBar, {
AnalysisLevelBarSkeleton,
AnalysisResultLevel,
} from './analysis-level-bar';
export type AnalysisResultForDisplay = Pick<
UserAnalysisElement,
| 'norm_status'
| 'response_value'
| 'unit'
| 'norm_lower_included'
| 'norm_upper_included'
| 'norm_lower'
| 'norm_upper'
| 'response_time'
>;
export enum AnalysisStatus {
NORMAL = 0,
MEDIUM = 1,
HIGH = 2,
}
export type AnalysisResultForDisplay = {
norm_status?: number | null;
response_value?: number | null;
unit?: string | null;
norm_lower_included?: boolean | null;
norm_upper_included?: boolean | null;
norm_lower?: number | null;
norm_upper?: number | null;
response_time?: string | null;
nestedElements?: NestedAnalysisElement[];
};
const AnalysisDoctor = ({
analysisElement,
@@ -48,38 +42,41 @@ const AnalysisDoctor = ({
endIcon?: ReactNode | null;
}) => {
const name = analysisElement.analysis_name_lab || '';
const status = results?.norm_status || AnalysisStatus.NORMAL;
const status = results?.norm_status;
const value = results?.response_value || 0;
const unit = results?.unit || '';
const normLowerIncluded = results?.norm_lower_included || false;
const normUpperIncluded = results?.norm_upper_included || false;
const normLower = results?.norm_lower || 0;
const normUpper = results?.norm_upper || 0;
const normLower = results?.norm_lower ?? null;
const normUpper = results?.norm_upper ?? null;
const [showTooltip, setShowTooltip] = useState(false);
const analysisResultLevel = useMemo(() => {
if (!results) {
if (!results || status === null || status === undefined) {
return null;
}
const isUnderNorm = value < normLower;
if (isUnderNorm) {
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.LOW;
default:
return AnalysisResultLevel.VERY_LOW;
}
}
switch (status) {
case AnalysisStatus.MEDIUM:
return AnalysisResultLevel.HIGH;
case AnalysisStatus.HIGH:
return AnalysisResultLevel.VERY_HIGH;
case 1:
return AnalysisResultLevel.WARNING;
case 2:
return AnalysisResultLevel.CRITICAL;
case 0:
default:
return AnalysisResultLevel.NORMAL;
}
}, [results, value, normLower]);
}, [results, status]);
const normRangeText = useMemo(() => {
if (normLower === null && normUpper === null) {
return null;
}
return `${normLower ?? '...'} - ${normUpper ?? '...'}`;
}, [normLower, normUpper]);
const nestedElements = results?.nestedElements ?? null;
const hasNestedElements =
Array.isArray(nestedElements) && nestedElements.length > 0;
const isAnalysisLevelBarHidden = isCancelled || !results || hasNestedElements;
return (
<div className="border-border rounded-lg border px-5">
@@ -110,37 +107,52 @@ const AnalysisDoctor = ({
</div>
)}
</div>
{results ? (
{isAnalysisLevelBarHidden ? null : (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">{value}</div>
<div className="text-muted-foreground text-sm">{unit}</div>
</div>
<div className="text-muted-foreground mx-8 flex flex-col-reverse gap-2 text-center text-sm sm:block sm:gap-0">
{normLower} - {normUpper}
{normRangeText}
<div>
<Trans i18nKey="analysis-results:results.range.normal" />
</div>
</div>
<AnalysisLevelBar
results={results}
normLowerIncluded={normLowerIncluded}
normUpperIncluded={normUpperIncluded}
level={analysisResultLevel!}
level={analysisResultLevel}
normRangeText={normRangeText}
/>
{endIcon || <div className="mx-2 w-4" />}
</>
) : isCancelled ? null : (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">
<Trans i18nKey="analysis-results:waitingForResults" />
</div>
</div>
<div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton />
</>
)}
{(() => {
// If parent has nested elements, don't show anything
if (hasNestedElements) {
return null;
}
// If we're showing the level bar, don't show waiting
if (!isAnalysisLevelBarHidden) {
return null;
}
// If cancelled, don't show waiting
if (isCancelled) {
return null;
}
// Otherwise, show waiting for results
return (
<>
<div className="flex items-center gap-3 sm:ml-auto">
<div className="font-semibold">
<Trans i18nKey="analysis-results:waitingForResults" />
</div>
</div>
<div className="mx-8 w-[60px]"></div>
<AnalysisLevelBarSkeleton />
</>
);
})()}
</div>
</div>
);

View File

@@ -7,11 +7,9 @@ import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis-doctor';
export enum AnalysisResultLevel {
VERY_LOW = 0,
LOW = 1,
NORMAL = 2,
HIGH = 3,
VERY_HIGH = 4,
NORMAL = 'NORMAL',
WARNING = 'WARNING',
CRITICAL = 'CRITICAL',
}
const Level = ({
@@ -20,17 +18,19 @@ const Level = ({
isFirst = false,
isLast = false,
arrowLocation,
normRangeText,
}: {
isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean;
isLast?: boolean;
arrowLocation?: number;
normRangeText?: string | null;
}) => {
return (
<div
className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-20': !isActive,
'opacity-60': !isActive,
'rounded-l-lg': isFirst,
'rounded-r-lg': isLast,
})}
@@ -38,11 +38,32 @@ const Level = ({
{isActive && (
<div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]"
style={{ left: `${arrowLocation}%` }}
{...(arrowLocation
? {
style: {
left: `${arrowLocation}%`,
...(arrowLocation > 92.5 && { left: '92.5%' }),
...(arrowLocation < 7.5 && { left: '7.5%' }),
},
}
: {})}
>
<ArrowDown strokeWidth={2} />
</div>
)}
{color === 'success' && typeof normRangeText === 'string' && (
<p
className={cn(
'text-muted-foreground absolute bottom-[-18px] left-3/8 text-xs font-bold whitespace-nowrap',
{
'opacity-60': isActive,
},
)}
>
{normRangeText}
</p>
)}
</div>
);
};
@@ -50,81 +71,148 @@ const Level = ({
export const AnalysisLevelBarSkeleton = () => {
return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" />
<Level color="gray-200" isFirst isLast />
</div>
);
};
const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level,
results,
normRangeText,
}: {
normLowerIncluded?: boolean;
normUpperIncluded?: boolean;
level: AnalysisResultLevel;
level: AnalysisResultLevel | null;
results: AnalysisResultForDisplay;
normRangeText: string | null;
}) => {
const {
norm_lower: lower,
norm_upper: upper,
response_value: value,
} = results;
const arrowLocation = useMemo(() => {
if (value < lower!) {
return 0;
}
if (normLowerIncluded || normUpperIncluded) {
// Calculate arrow position based on value within normal range
const arrowLocation = useMemo(() => {
// If no response value, center the arrow
if (value === null || value === undefined) {
return 50;
}
const calculated = ((value - lower!) / (upper! - lower!)) * 100;
if (calculated > 100) {
return 100;
// If no normal ranges defined, center the arrow
if (lower === null && upper === null) {
return 50;
}
return calculated;
// If only upper bound exists
if (lower === null && upper !== null) {
if (value <= upper!) {
return Math.min(75, (value / upper!) * 75); // Show in left 75% of normal range
}
return 100; // Beyond upper bound
}
// If only lower bound exists
if (upper === null && lower !== null) {
if (value >= lower!) {
// Value is in normal range (above lower bound)
// Position proportionally in the normal range section
const normalizedPosition = Math.min(
(value - lower!) / (lower! * 0.5),
1,
); // Use 50% of lower as scale
return normalizedPosition * 100;
}
// Value is below lower bound - position in the "below normal" section
const belowPosition = Math.max(0, Math.min(1, value / lower!));
return belowPosition * 100;
}
// Both bounds exist
if (lower !== null && upper !== null) {
if (value < lower!) {
return 0; // Below normal range
}
if (value > upper!) {
return 100; // Above normal range
}
// Within normal range
return ((value - lower!) / (upper! - lower!)) * 100;
}
return 50; // Fallback
}, [value, upper, lower]);
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo(
() => [
level === AnalysisResultLevel.VERY_LOW,
level === AnalysisResultLevel.LOW,
level === AnalysisResultLevel.HIGH,
level === AnalysisResultLevel.VERY_HIGH,
],
[level, value, upper, lower],
);
// Determine level states based on normStatus
const isNormal = level === AnalysisResultLevel.NORMAL;
const isWarning = level === AnalysisResultLevel.WARNING;
const isCritical = level === AnalysisResultLevel.CRITICAL;
const isPending = level === null;
const hasAbnormalLevel = isVeryLow || isLow || isHigh || isVeryHigh;
// Show appropriate levels based on available norm bounds
const hasLowerBound = lower !== null;
// Calculate level configuration (must be called before any returns)
const [first, second, third] = useMemo(() => {
const [warning, normal, critical] = [
{
isActive: isWarning,
color: 'warning',
...(isWarning ? { arrowLocation } : {}),
},
{
isActive: isNormal,
color: 'success',
normRangeText,
...(isNormal ? { arrowLocation } : {}),
},
{
isActive: isCritical,
color: 'destructive',
isLast: true,
...(isCritical ? { arrowLocation } : {}),
},
] as const;
if (!hasLowerBound) {
return [{ ...normal, isFirst: true }, warning, critical] as const;
}
return [
{ ...warning, isFirst: true },
normal,
{ ...critical, isLast: true },
] as const;
}, [
arrowLocation,
normRangeText,
isNormal,
isWarning,
isCritical,
hasLowerBound,
]);
// If pending results, show gray bar
if (isPending) {
return (
<div className="w-60% mt-4 flex h-3 max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
<Level color="gray-200" isFirst isLast />
</div>
);
}
return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]">
{normLowerIncluded && (
<>
<Level isActive={isVeryLow} color="destructive" isFirst />
<Level isActive={isLow} color="warning" />
</>
)}
<Level
isFirst={!normLowerIncluded}
isLast={!normUpperIncluded}
{...(hasAbnormalLevel
? { color: 'warning', isActive: false }
: { color: 'success', isActive: true })}
arrowLocation={arrowLocation}
/>
{normUpperIncluded && (
<>
<Level isActive={isHigh} color="warning" />
<Level isActive={isVeryHigh} color="destructive" isLast />
</>
<div
className={cn(
'flex h-3 gap-1',
'mt-4 sm:mt-0',
'w-[60%] sm:w-[35%]',
'min-w-[50vw] sm:min-w-auto',
'max-w-[360px]',
)}
>
<Level {...first} />
<Level {...second} />
<Level {...third} />
</div>
);
};

View File

@@ -1,5 +1,7 @@
'use client';
import React from 'react';
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next';
@@ -23,55 +25,25 @@ export default function DoctorAnalysisWrapper({
const { t } = useTranslation();
return (
<Collapsible className="w-full" key={analysisData.id}>
<CollapsibleTrigger
disabled={!analysisData.latestPreviousAnalysis}
asChild
>
<div className="[&[data-state=open]_.caret-icon]:rotate-180">
<AnalysisDoctor
startIcon={
analysisData.latestPreviousAnalysis && (
<CaretDownIcon className="caret-icon transition-transform duration-200" />
)
}
endIcon={
analysisData.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
/>
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:
</strong>{' '}
{analysisData.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: analysisData.analysis_name,
}}
results={analysisData}
/>
</div>
</CollapsibleTrigger>
{analysisData.latestPreviousAnalysis && (
<CollapsibleContent>
<div className="my-1 flex flex-col">
<>
<Collapsible className="w-full" key={analysisData.id}>
<CollapsibleTrigger
disabled={!analysisData.latestPreviousAnalysis}
asChild
>
<div className="[&[data-state=open]_.caret-icon]:rotate-180">
<AnalysisDoctor
startIcon={
analysisData.latestPreviousAnalysis && (
<CaretDownIcon className="caret-icon transition-transform duration-200" />
)
}
endIcon={
analysisData.latestPreviousAnalysis.comment && (
analysisData.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.latestPreviousAnalysis.comment}
content={analysisData.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
@@ -79,25 +51,103 @@ export default function DoctorAnalysisWrapper({
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:{' '}
</strong>
{analysisData.latestPreviousAnalysis.comment}
<Trans i18nKey="doctor:labComment" />:
</strong>{' '}
{analysisData.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: t('doctor:previousResults', {
date: formatDate(
analysisData.latestPreviousAnalysis.response_time,
),
}),
analysis_name_lab: analysisData.analysis_name,
}}
results={analysisData.latestPreviousAnalysis}
results={analysisData}
/>
</div>
</CollapsibleContent>
)}
</Collapsible>
</CollapsibleTrigger>
{analysisData.latestPreviousAnalysis && (
<CollapsibleContent>
<div className="my-1 flex flex-col gap-2">
<AnalysisDoctor
endIcon={
analysisData.latestPreviousAnalysis.comment && (
<>
<div className="xs:flex hidden">
<InfoTooltip
content={analysisData.latestPreviousAnalysis.comment}
icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" />
}
/>
</div>
<p className="xs:hidden">
<strong>
<Trans i18nKey="doctor:labComment" />:{' '}
</strong>
{analysisData.latestPreviousAnalysis.comment}
</p>
</>
)
}
analysisElement={{
analysis_name_lab: t('doctor:previousResults', {
date: formatDate(
analysisData.latestPreviousAnalysis.response_time!,
),
}),
}}
results={analysisData.latestPreviousAnalysis}
/>
{analysisData.latestPreviousAnalysis.nestedElements?.map(
(nestedElement, nestedIndex) => (
<div
key={`prev-nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
className="ml-8"
>
<AnalysisDoctor
analysisElement={{
analysis_name_lab: nestedElement.analysisNameLab ?? '',
}}
results={{
norm_status: nestedElement.normStatus,
response_value: nestedElement.responseValue,
unit: nestedElement.unit,
norm_lower: nestedElement.normLower,
norm_upper: nestedElement.normUpper,
norm_lower_included: nestedElement.normLowerIncluded,
norm_upper_included: nestedElement.normUpperIncluded,
response_time: nestedElement.responseTime,
}}
/>
</div>
),
)}
</div>
</CollapsibleContent>
)}
</Collapsible>
{analysisData.nestedElements?.map((nestedElement, nestedIndex) => (
<div
key={`nested-${nestedElement.analysisElementOriginalId}-${nestedIndex}`}
className="ml-8"
>
<AnalysisDoctor
analysisElement={{
analysis_name_lab: nestedElement.analysisNameLab ?? '',
}}
results={{
norm_status: nestedElement.normStatus,
response_value: nestedElement.responseValue,
unit: nestedElement.unit,
norm_lower: nestedElement.normLower,
norm_upper: nestedElement.normUpper,
norm_lower_included: nestedElement.normLowerIncluded,
norm_upper_included: nestedElement.normUpperIncluded,
response_time: nestedElement.responseTime,
}}
/>
</div>
))}
</>
);
}

View File

@@ -178,7 +178,7 @@ export default function ResultsTable({
<TableCell>
<Trans
i18nKey={
resultsReceived === elementsInOrder
resultsReceived >= elementsInOrder
? 'doctor:resultsTable.responsesReceived'
: 'doctor:resultsTable.waitingForNr'
}

View File

@@ -13,6 +13,7 @@ import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { formatDateAndTime } from '@kit/shared/utils';
import { withI18n } from '~/lib/i18n/with-i18n';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
import {
@@ -22,7 +23,7 @@ import {
import Analysis from '../_components/analysis';
export default async function AnalysisResultsPage({
async function AnalysisResultsPage({
params,
}: {
params: Promise<{
@@ -150,3 +151,5 @@ export default async function AnalysisResultsPage({
</>
);
}
export default withI18n(AnalysisResultsPage);

View File

@@ -1,6 +1,7 @@
import Link from 'next/link';
import { createI18nServerInstance } from '@/lib/i18n/i18n.server';
import { withI18n } from '~/lib/i18n/with-i18n';
import { PageBody, PageHeader } from '@/packages/ui/src/makerkit/page';
import { toArray } from '@kit/shared/utils';
@@ -17,7 +18,7 @@ export async function generateMetadata() {
};
}
export default async function MontonioCheckoutCallbackErrorPage({
async function MontonioCheckoutCallbackErrorPage({
searchParams,
}: {
searchParams: Promise<{ reasonFailed: string }>;
@@ -61,3 +62,5 @@ export default async function MontonioCheckoutCallbackErrorPage({
</div>
);
}
export default withI18n(MontonioCheckoutCallbackErrorPage);

View File

@@ -1,8 +1,10 @@
import { withI18n } from '~/lib/i18n/with-i18n';
import { loadCurrentUserAccount } from '../../_lib/server/load-user-account';
import AccountPreferencesForm from '../_components/account-preferences-form';
import SettingsSectionHeader from '../_components/settings-section-header';
export default async function PreferencesPage() {
async function PreferencesPage() {
const { account } = await loadCurrentUserAccount();
return (
@@ -17,3 +19,5 @@ export default async function PreferencesPage() {
</div>
);
}
export default withI18n(PreferencesPage);

View File

@@ -1,8 +1,9 @@
import { MultiFactorAuthFactorsList } from '@kit/accounts/components';
import { withI18n } from '~/lib/i18n/with-i18n';
import SettingsSectionHeader from '../_components/settings-section-header';
export default function SecuritySettingsPage() {
async function SecuritySettingsPage() {
return (
<div className="mx-auto w-full bg-white p-6">
<div className="space-y-6">
@@ -15,3 +16,5 @@ export default function SecuritySettingsPage() {
</div>
);
}
export default withI18n(SecuritySettingsPage);

View File

@@ -0,0 +1,12 @@
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
export async function getAnalysisResponseAdmin(analysisOrderId: number) {
const { data } = await getSupabaseServerAdminClient()
.schema('medreport')
.from('analysis_responses')
.select('*')
.eq('analysis_order_id', analysisOrderId)
.single()
.throwOnError();
return data;
}

View File

@@ -12,7 +12,7 @@ import { createBillingEventHandlerService } from './billing-event-handler.servic
type ClientProvider = () => SupabaseClient<Database>;
// the billing provider from the database
type BillingProvider = Enums<'billing_provider'>;
type BillingProvider = Enums<{ schema: 'medreport' }, 'billing_provider'>;
/**
* @name getBillingEventHandlerService

View File

@@ -19,10 +19,10 @@ import { initializeEmailI18n } from '../lib/i18n';
export async function renderAllResultsReceivedEmail({
language,
analysisOrderId,
analysisResponseId,
}: {
language: string;
analysisOrderId: number;
analysisResponseId: number;
}) {
const namespace = 'all-results-received-email';
@@ -57,13 +57,13 @@ export async function renderAllResultsReceivedEmail({
{t(`${namespace}:openOrdersHeading`)}
</Text>
<EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
>
{t(`${namespace}:linkText`)}
</EmailButton>
<Text>
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
</Text>
<CommonFooter t={t} />
</EmailContent>

View File

@@ -19,10 +19,10 @@ import { initializeEmailI18n } from '../lib/i18n';
export async function renderFirstResultsReceivedEmail({
language,
analysisOrderId,
analysisResponseId,
}: {
language: string;
analysisOrderId: number;
analysisResponseId: number;
}) {
const namespace = 'first-results-received-email';
@@ -61,14 +61,14 @@ export async function renderFirstResultsReceivedEmail({
</Text>
<EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
>
{t(`${namespace}:linkText`)}
</EmailButton>
<Text>
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
</Text>
<CommonFooter t={t} />
</EmailContent>

View File

@@ -52,6 +52,23 @@ export const AnalysisResponsesSchema = z.object({
});
export type AnalysisResponses = z.infer<typeof AnalysisResponsesSchema>;
// Nested element schema (used recursively)
export const NestedAnalysisElementSchema = z.object({
analysisElementOriginalId: z.string(),
analysisName: z.string().optional().nullable(),
unit: z.string().nullable(),
normLower: z.number().nullable(),
normUpper: z.number().nullable(),
normStatus: z.number().nullable(),
responseTime: z.string().nullable(),
responseValue: z.number().nullable(),
normLowerIncluded: z.boolean(),
normUpperIncluded: z.boolean(),
status: z.number(),
analysisNameLab: z.string().optional().nullable(),
});
export type NestedAnalysisElement = z.infer<typeof NestedAnalysisElementSchema>;
export const AnalysisResponseSchema = z.object({
id: z.number(),
analysis_response_id: z.number(),
@@ -69,6 +86,7 @@ export const AnalysisResponseSchema = z.object({
analysis_name: z.string().nullable(),
analysis_responses: AnalysisResponsesSchema,
comment: z.string().nullable(),
nestedElements: z.array(NestedAnalysisElementSchema).optional(),
latestPreviousAnalysis: z
.object({
id: z.number(),
@@ -86,6 +104,7 @@ export const AnalysisResponseSchema = z.object({
updated_at: z.string().nullable(),
analysis_name: z.string().nullable(),
comment: z.string().nullable(),
nestedElements: z.array(NestedAnalysisElementSchema).optional(),
})
.optional()
.nullable(),

View File

@@ -5,12 +5,16 @@ import { isBefore } from 'date-fns';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getLogger } from '@kit/shared/logger';
import { getFullName } from '@kit/shared/utils';
import type { UuringuVastus, ResponseUuring } from '@kit/shared/types/medipost-analysis';
import { getFullName, toArray } from '@kit/shared/utils';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createUserAnalysesApi } from '@kit/user-analyses/api';
import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service';
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
import {
AnalysisResultDetails,
NestedAnalysisElement,
} from '../schema/doctor-analysis-detail-view.schema';
import {
AnalysisResponseBase,
DoctorAnalysisFeedbackTable,
@@ -20,6 +24,63 @@ import {
} from '../schema/doctor-analysis.schema';
import { ErrorReason } from '../schema/error.type';
function mapUuringVastus({ uuringVastus }: { uuringVastus?: UuringuVastus }) {
const vastuseVaartus = uuringVastus?.VastuseVaartus;
const responseValue = (() => {
const valueAsNumber = Number(vastuseVaartus);
if (isNaN(valueAsNumber)) {
return null;
}
return valueAsNumber;
})();
const responseValueNumber = Number(responseValue);
const responseValueIsNumeric = !isNaN(responseValueNumber);
return {
normLower: uuringVastus?.NormAlum?.['#text'] ?? null,
normUpper: uuringVastus?.NormYlem?.['#text'] ?? null,
normStatus: (uuringVastus?.NormiStaatus ?? null) as number | null,
responseTime: uuringVastus?.VastuseAeg ?? null,
responseValue:
responseValueIsNumeric ? (responseValueNumber ?? null) : null,
normLowerIncluded:
uuringVastus?.NormAlum?.['@_kaasaarvatud']?.toLowerCase() === 'jah',
normUpperIncluded:
uuringVastus?.NormYlem?.['@_kaasaarvatud']?.toLowerCase() === 'jah',
};
}
function parseNestedElements(
originalResponseElement: ResponseUuring | null | undefined,
status: number,
): NestedAnalysisElement[] {
if (!originalResponseElement?.UuringuElement) {
return [];
}
const nestedElements = toArray(originalResponseElement.UuringuElement);
return nestedElements.map<NestedAnalysisElement>((element) => {
const mappedResponse = mapUuringVastus({
uuringVastus: element.UuringuVastus as UuringuVastus | undefined,
});
return {
analysisElementOriginalId: element.UuringId,
analysisName: undefined, // Will be populated later from analysis_elements table
unit: element.Mootyhik ?? null,
normLower: mappedResponse.normLower,
normUpper: mappedResponse.normUpper,
normStatus: mappedResponse.normStatus,
responseTime: mappedResponse.responseTime,
responseValue: mappedResponse.responseValue,
normLowerIncluded: mappedResponse.normLowerIncluded,
normUpperIncluded: mappedResponse.normUpperIncluded,
status,
analysisNameLab: element.UuringNimi,
};
});
}
async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
const supabase = getSupabaseServerClient();
@@ -186,12 +247,13 @@ export async function getUserInProgressResponses({
`,
{ count: 'exact' },
)
.neq('status', 'ON_HOLD')
.eq('order_status', 'COMPLETED')
.in('analysis_order_id', analysisOrderIds)
.range(offset, offset + pageSize - 1)
.order('created_at', { ascending: false });
if (error) {
console.error("Failed to get analysis responses", error);
throw new Error('Something went wrong');
}
@@ -388,7 +450,8 @@ export async function getAnalysisResultsForDoctor(
.from(`analysis_response_elements`)
.select(
`*,
analysis_responses(user_id, analysis_order:analysis_order_id(id,medusa_order_id, analysis_element_ids))`,
analysis_responses(user_id, analysis_order:analysis_order_id(id,medusa_order_id, analysis_element_ids)),
original_response_element`,
)
.eq('analysis_response_id', analysisResponseId);
@@ -404,8 +467,14 @@ export async function getAnalysisResultsForDoctor(
const medusaOrderId =
firstAnalysisResponse?.analysis_responses?.analysis_order?.medusa_order_id;
if (!analysisResponsesData?.length || !userId || !medusaOrderId) {
throw new Error('Failed to retrieve full analysis data.');
if (!analysisResponsesData?.length) {
throw new Error('No analysis responses data found.');
}
if (!userId) {
throw new Error('No user id found.');
}
if (!medusaOrderId) {
throw new Error('No medusa order id found.');
}
const responseElementAnalysisElementOriginalIds = analysisResponsesData.map(
@@ -446,7 +515,8 @@ export async function getAnalysisResultsForDoctor(
*,
analysis_responses!inner(
user_id
)
),
original_response_element
`,
)
.in(
@@ -485,8 +555,14 @@ export async function getAnalysisResultsForDoctor(
preferred_locale,
} = accountWithParams[0];
// Parse nested elements for current and previous analyses
const analysisResponseElementsWithPreviousData = [];
for (const analysisResponseElement of analysisResponsesData) {
const nestedElements = parseNestedElements(
analysisResponseElement.original_response_element as ResponseUuring,
Number(analysisResponseElement.status),
);
const latestPreviousAnalysis = previousAnalyses.find(
({ analysis_element_original_id, response_time }) => {
if (response_time && analysisResponseElement.response_time) {
@@ -501,12 +577,95 @@ export async function getAnalysisResultsForDoctor(
}
},
);
// Parse nested elements for previous analysis if it exists
const latestPreviousAnalysisWithNested = latestPreviousAnalysis
? {
...latestPreviousAnalysis,
nestedElements: parseNestedElements(
latestPreviousAnalysis.original_response_element as ResponseUuring,
Number(latestPreviousAnalysis.status),
),
}
: undefined;
analysisResponseElementsWithPreviousData.push({
...analysisResponseElement,
latestPreviousAnalysis,
nestedElements,
latestPreviousAnalysis: latestPreviousAnalysisWithNested,
});
}
// Collect all nested element IDs to fetch their names
const nestedElementIds = analysisResponseElementsWithPreviousData
.flatMap((element) => [
...(element.nestedElements?.map((ne) => ne.analysisElementOriginalId) ??
[]),
...(element.latestPreviousAnalysis?.nestedElements?.map(
(ne) => ne.analysisElementOriginalId,
) ?? []),
])
.filter(Boolean);
// Fetch analysis names for nested elements
if (nestedElementIds.length > 0) {
const { data: nestedAnalysisElements, error: nestedAnalysisElementsError } =
await supabase
.schema('medreport')
.from('analysis_elements')
.select('*')
.in('analysis_id_original', nestedElementIds);
if (!nestedAnalysisElementsError && nestedAnalysisElements) {
// Populate analysis names for current nested elements
for (const element of analysisResponseElementsWithPreviousData) {
if (element.nestedElements) {
for (const nestedElement of element.nestedElements) {
const analysisElement = nestedAnalysisElements.find(
(ae) =>
ae.analysis_id_original ===
nestedElement.analysisElementOriginalId,
);
if (analysisElement) {
nestedElement.analysisName =
analysisElement.analysis_name_lab as string | undefined;
}
}
// Sort nested elements by name
element.nestedElements.sort(
(a, b) => a.analysisName?.localeCompare(b.analysisName ?? '') ?? 0,
);
}
// Populate analysis names for previous nested elements
if (element.latestPreviousAnalysis?.nestedElements) {
for (const nestedElement of element.latestPreviousAnalysis
.nestedElements) {
const analysisElement = nestedAnalysisElements.find(
(ae) =>
ae.analysis_id_original ===
nestedElement.analysisElementOriginalId,
);
if (analysisElement) {
nestedElement.analysisName =
analysisElement.analysis_name_lab as string | undefined;
}
}
// Sort nested elements by name
element.latestPreviousAnalysis.nestedElements.sort(
(a, b) => a.analysisName?.localeCompare(b.analysisName ?? '') ?? 0,
);
}
}
} else {
console.error(
'Failed to get nested analysis elements by ids=',
nestedElementIds,
nestedAnalysisElementsError,
);
}
}
return {
analysisResponse: analysisResponseElementsWithPreviousData,
order: {

View File

@@ -16,6 +16,7 @@ import {
getDoctorAccounts,
getUserContactAdmin,
} from '~/lib/services/account.service';
import { getAnalysisResponseAdmin } from '~/lib/services/analysis-response.service';
import {
NotificationAction,
createNotificationLog,
@@ -74,14 +75,14 @@ class OrderWebhooksService {
}
logger.info(ctx, 'Status change processed. No notifications to send.');
} catch (e: any) {
} catch (e) {
if (actions.length)
await Promise.all(
actions.map((action) =>
createNotificationLog({
action,
status: 'FAIL',
comment: e?.message,
comment: e instanceof Error ? e.message : 'Unknown error',
relatedRecordId: analysisOrder.id,
}),
),
@@ -201,11 +202,11 @@ class OrderWebhooksService {
status: 'SUCCESS',
relatedRecordId: orderCart.order_id,
});
} catch (e: any) {
} catch (e) {
createNotificationLog({
action: NotificationAction.TTO_ORDER_CONFIRMATION,
status: 'FAIL',
comment: e?.message,
comment: e instanceof Error ? e.message : 'Unknown error',
relatedRecordId: ttoReservation.id,
});
logger.error(
@@ -345,10 +346,12 @@ class OrderWebhooksService {
.map(({ email }) => email)
.filter((email): email is string => !!email);
const analysisResponse = await getAnalysisResponseAdmin(analysisOrder.id);
await sendEmailFromTemplate(
renderFirstResultsReceivedEmail,
{
analysisOrderId: analysisOrder.id,
analysisResponseId: analysisResponse.id,
language: 'et',
},
doctorEmails,
@@ -380,10 +383,12 @@ class OrderWebhooksService {
return;
}
const analysisResponse = await getAnalysisResponseAdmin(analysisOrder.id);
await sendEmailFromTemplate(
renderAllResultsReceivedEmail,
{
analysisOrderId: analysisOrder.id,
analysisResponseId: analysisResponse.id,
language: 'et',
},
assignedDoctorEmail,