14 Commits

33 changed files with 1263 additions and 590 deletions

View File

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

View File

@@ -1,7 +1,4 @@
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; import MedipostPrivateMessageSync from '~/lib/services/medipost/medipostPrivateMessageSync.service';
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
import { readPrivateMessageResponse } from '~/lib/services/medipost/medipostPrivateMessage.service';
type ProcessedMessage = { type ProcessedMessage = {
messageId: string; messageId: string;
@@ -19,30 +16,22 @@ type GroupedResults = {
export default async function syncAnalysisResults() { export default async function syncAnalysisResults() {
console.info('Syncing analysis results'); console.info('Syncing analysis results');
const supabase = getSupabaseServerAdminClient(); const sync = new MedipostPrivateMessageSync();
const api = createUserAnalysesApi(supabase);
const processedMessages: ProcessedMessage[] = []; const processedMessages: ProcessedMessage[] = [];
const excludedMessageIds: string[] = []; const excludedMessageIds: string[] = [];
while (true) { while (true) {
const result = await readPrivateMessageResponse({ excludedMessageIds }); const result = await sync.handleNextPrivateMessage({ excludedMessageIds });
if (result.messageId) {
processedMessages.push(result as ProcessedMessage);
}
await api.sendAnalysisResultsNotification({ const { messageId } = result;
hasFullAnalysisResponse: result.hasFullAnalysisResponse, if (!messageId) {
hasPartialAnalysisResponse: result.hasAnalysisResponse,
analysisOrderId: result.analysisOrderId,
});
if (!result.messageId) {
console.info('No more messages to process'); console.info('No more messages to process');
break; break;
} }
if (!excludedMessageIds.includes(result.messageId)) { processedMessages.push(result as ProcessedMessage);
excludedMessageIds.push(result.messageId); if (!excludedMessageIds.includes(messageId)) {
excludedMessageIds.push(messageId);
} else { } else {
break; break;
} }

View File

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

View File

@@ -54,6 +54,7 @@ export async function POST(request: Request) {
action: 'send_fake_analysis_results_to_medipost', action: 'send_fake_analysis_results_to_medipost',
xml: messageXml, xml: messageXml,
medusaOrderId, medusaOrderId,
medipostPrivateMessageId: `fake-response-${Date.now()}`,
}); });
await sendPrivateMessageTestResponse({ messageXml }); await sendPrivateMessageTestResponse({ messageXml });
} catch (error) { } catch (error) {

View File

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

View File

@@ -7,11 +7,9 @@ import { cn } from '@kit/ui/utils';
import { AnalysisResultForDisplay } from './analysis-doctor'; import { AnalysisResultForDisplay } from './analysis-doctor';
export enum AnalysisResultLevel { export enum AnalysisResultLevel {
VERY_LOW = 0, NORMAL = 'NORMAL',
LOW = 1, WARNING = 'WARNING',
NORMAL = 2, CRITICAL = 'CRITICAL',
HIGH = 3,
VERY_HIGH = 4,
} }
const Level = ({ const Level = ({
@@ -20,17 +18,19 @@ const Level = ({
isFirst = false, isFirst = false,
isLast = false, isLast = false,
arrowLocation, arrowLocation,
normRangeText,
}: { }: {
isActive?: boolean; isActive?: boolean;
color: 'destructive' | 'success' | 'warning' | 'gray-200'; color: 'destructive' | 'success' | 'warning' | 'gray-200';
isFirst?: boolean; isFirst?: boolean;
isLast?: boolean; isLast?: boolean;
arrowLocation?: number; arrowLocation?: number;
normRangeText?: string | null;
}) => { }) => {
return ( return (
<div <div
className={cn(`bg-${color} relative h-3 flex-1`, { className={cn(`bg-${color} relative h-3 flex-1`, {
'opacity-20': !isActive, 'opacity-60': !isActive,
'rounded-l-lg': isFirst, 'rounded-l-lg': isFirst,
'rounded-r-lg': isLast, 'rounded-r-lg': isLast,
})} })}
@@ -38,11 +38,32 @@ const Level = ({
{isActive && ( {isActive && (
<div <div
className="absolute top-[-14px] left-1/2 -translate-x-1/2 rounded-[10px] bg-white p-[2px]" 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} /> <ArrowDown strokeWidth={2} />
</div> </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> </div>
); );
}; };
@@ -50,81 +71,148 @@ const Level = ({
export const AnalysisLevelBarSkeleton = () => { export const AnalysisLevelBarSkeleton = () => {
return ( return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]"> <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> </div>
); );
}; };
const AnalysisLevelBar = ({ const AnalysisLevelBar = ({
normLowerIncluded = true,
normUpperIncluded = true,
level, level,
results, results,
normRangeText,
}: { }: {
normLowerIncluded?: boolean; level: AnalysisResultLevel | null;
normUpperIncluded?: boolean;
level: AnalysisResultLevel;
results: AnalysisResultForDisplay; results: AnalysisResultForDisplay;
normRangeText: string | null;
}) => { }) => {
const { const {
norm_lower: lower, norm_lower: lower,
norm_upper: upper, norm_upper: upper,
response_value: value, response_value: value,
} = results; } = 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; return 50;
} }
const calculated = ((value - lower!) / (upper! - lower!)) * 100; // If no normal ranges defined, center the arrow
if (lower === null && upper === null) {
if (calculated > 100) { return 50;
return 100;
} }
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]); }, [value, upper, lower]);
const [isVeryLow, isLow, isHigh, isVeryHigh] = useMemo( // Determine level states based on normStatus
() => [ const isNormal = level === AnalysisResultLevel.NORMAL;
level === AnalysisResultLevel.VERY_LOW, const isWarning = level === AnalysisResultLevel.WARNING;
level === AnalysisResultLevel.LOW, const isCritical = level === AnalysisResultLevel.CRITICAL;
level === AnalysisResultLevel.HIGH, const isPending = level === null;
level === AnalysisResultLevel.VERY_HIGH,
],
[level, value, upper, lower],
);
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 ( return (
<div className="mt-4 flex h-3 w-[60%] max-w-[360px] gap-1 sm:mt-0 sm:w-[35%]"> <div
{normLowerIncluded && ( className={cn(
<> 'flex h-3 gap-1',
<Level isActive={isVeryLow} color="destructive" isFirst /> 'mt-4 sm:mt-0',
<Level isActive={isLow} color="warning" /> 'w-[60%] sm:w-[35%]',
</> 'min-w-[50vw] sm:min-w-auto',
)} 'max-w-[360px]',
<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 />
</>
)} )}
>
<Level {...first} />
<Level {...second} />
<Level {...third} />
</div> </div>
); );
}; };

View File

@@ -1,5 +1,7 @@
'use client'; 'use client';
import React from 'react';
import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons'; import { CaretDownIcon, QuestionMarkCircledIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -23,55 +25,25 @@ export default function DoctorAnalysisWrapper({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Collapsible className="w-full" key={analysisData.id}> <>
<CollapsibleTrigger <Collapsible className="w-full" key={analysisData.id}>
disabled={!analysisData.latestPreviousAnalysis} <CollapsibleTrigger
asChild disabled={!analysisData.latestPreviousAnalysis}
> asChild
<div className="[&[data-state=open]_.caret-icon]:rotate-180"> >
<AnalysisDoctor <div className="[&[data-state=open]_.caret-icon]:rotate-180">
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">
<AnalysisDoctor <AnalysisDoctor
startIcon={
analysisData.latestPreviousAnalysis && (
<CaretDownIcon className="caret-icon transition-transform duration-200" />
)
}
endIcon={ endIcon={
analysisData.latestPreviousAnalysis.comment && ( analysisData.comment && (
<> <>
<div className="xs:flex hidden"> <div className="xs:flex hidden">
<InfoTooltip <InfoTooltip
content={analysisData.latestPreviousAnalysis.comment} content={analysisData.comment}
icon={ icon={
<QuestionMarkCircledIcon className="mx-2 text-blue-800" /> <QuestionMarkCircledIcon className="mx-2 text-blue-800" />
} }
@@ -79,25 +51,103 @@ export default function DoctorAnalysisWrapper({
</div> </div>
<p className="xs:hidden"> <p className="xs:hidden">
<strong> <strong>
<Trans i18nKey="doctor:labComment" />:{' '} <Trans i18nKey="doctor:labComment" />:
</strong> </strong>{' '}
{analysisData.latestPreviousAnalysis.comment} {analysisData.comment}
</p> </p>
</> </>
) )
} }
analysisElement={{ analysisElement={{
analysis_name_lab: t('doctor:previousResults', { analysis_name_lab: analysisData.analysis_name,
date: formatDate(
analysisData.latestPreviousAnalysis.response_time,
),
}),
}} }}
results={analysisData.latestPreviousAnalysis} results={analysisData}
/> />
</div> </div>
</CollapsibleContent> </CollapsibleTrigger>
)} {analysisData.latestPreviousAnalysis && (
</Collapsible> <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> <TableCell>
<Trans <Trans
i18nKey={ i18nKey={
resultsReceived === elementsInOrder resultsReceived >= elementsInOrder
? 'doctor:resultsTable.responsesReceived' ? 'doctor:resultsTable.responsesReceived'
: 'doctor:resultsTable.waitingForNr' : 'doctor:resultsTable.waitingForNr'
} }

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react'; import { ArrowLeft, MessageCircle } from 'lucide-react';
@@ -20,6 +21,22 @@ const ErrorPage = ({
}) => { }) => {
useCaptureException(error); useCaptureException(error);
// Ignore next.js internal transient navigation errors that occur during auth state changes
const isTransientNavigationError =
error?.message?.includes('Error in input stream') ||
error?.message?.includes('AbortError') ||
(error?.name === 'ChunkLoadError');
useEffect(() => {
if (isTransientNavigationError && typeof window !== 'undefined') {
window.location.href = '/';
}
}, [isTransientNavigationError]);
if (isTransientNavigationError) {
return <div />;
}
return ( return (
<div className={'flex h-screen flex-1 flex-col'}> <div className={'flex h-screen flex-1 flex-col'}>
<SiteHeader /> <SiteHeader />

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react'; import { ArrowLeft, MessageCircle } from 'lucide-react';
@@ -20,6 +21,22 @@ const GlobalErrorPage = ({
reset: () => void; reset: () => void;
}) => { }) => {
useCaptureException(error); useCaptureException(error);
// Ignore next.js internal transient navigation errors that occur during auth state changes
const isTransientNavigationError =
error?.message?.includes('Error in input stream') ||
error?.message?.includes('AbortError') ||
(error?.name === 'ChunkLoadError');
useEffect(() => {
if (isTransientNavigationError && typeof window !== 'undefined') {
window.location.href = '/';
}
}, [isTransientNavigationError]);
if (isTransientNavigationError) {
return <div />;
}
return ( return (
<html> <html>

View File

@@ -11,6 +11,7 @@ import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { formatDateAndTime } from '@kit/shared/utils';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account'; import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis'; import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
@@ -103,7 +104,14 @@ export default async function AnalysisResultsPage({
<h6> <h6>
<Trans i18nKey={`orders:status.${analysisResponse.order.status}`} /> <Trans i18nKey={`orders:status.${analysisResponse.order.status}`} />
<ButtonTooltip <ButtonTooltip
content={`${analysisResponse.order.createdAt ? new Date(analysisResponse?.order?.createdAt).toLocaleString() : ''}`} content={
<Trans
i18nKey="analysis-results:orderCreatedAt"
values={{
createdAt: formatDateAndTime(analysisResponse.order.createdAt)
}}
/>
}
className="ml-6" className="ml-6"
/> />
</h6> </h6>

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

@@ -0,0 +1,142 @@
import type { PostgrestError } from "@supabase/supabase-js";
import { toArray } from '@kit/shared/utils';
import type { AnalysisOrder } from "~/lib/types/order";
import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element';
import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis';
import type {
MedipostAnalysisResult,
ResponseUuringuGrupp,
} from '@/packages/shared/src/types/medipost-analysis';
import { getAnalysisResponseElementsForGroup } from "./medipostPrivateMessage.service";
import {
getExistingAnalysisResponseElements,
upsertAnalysisResponse,
upsertAnalysisResponseElement,
} from "../analysis-order.service";
import type { Logger } from './types';
type AnalysisResponseElementMapped = Omit<
AnalysisResponseElement,
'created_at' | 'updated_at' | 'id' | 'analysis_response_id'
>;
export type SyncResult =
| {
isCompleted: boolean;
isPartial?: undefined;
}
| {
isPartial: boolean;
isCompleted?: undefined;
};
export default class MedipostAnalysisResultService {
public async storeAnalysisResult({
messageResponse: {
TellimuseNumber: orderNumber,
TellimuseOlek,
UuringuGrupp,
},
analysisOrder,
log,
}: {
messageResponse: Pick<
NonNullable<MedipostAnalysisResult>,
'TellimuseNumber' | 'TellimuseOlek' | 'UuringuGrupp'
>;
analysisOrder: AnalysisOrder;
log: Logger;
}): Promise<SyncResult> {
const orderStatus = AnalysisOrderStatus[TellimuseOlek];
const { analysisResponseId } = await upsertAnalysisResponse({
analysisOrderId: analysisOrder.id,
orderNumber,
orderStatus,
userId: analysisOrder.user_id,
});
const existingElements = await getExistingAnalysisResponseElements({
analysisResponseId,
});
const analysisGroups = toArray(UuringuGrupp);
log(`Order has results for ${analysisGroups.length} analysis groups`);
const newElements = await this.getNewAnalysisResponseElements({
analysisGroups,
existingElements,
log,
});
for (const element of newElements) {
try {
await upsertAnalysisResponseElement({
element: {
...element,
analysis_response_id: analysisResponseId,
},
});
} catch (e) {
log(
`Failed to create order response element for response id ${analysisResponseId}, element id '${element.analysis_element_original_id}'`,
"error",
e as PostgrestError,
);
}
}
const hasAllResults = await this.hasAllAnalysisResponseElements({
analysisResponseId,
analysisOrder,
});
log(`Order has ${hasAllResults ? 'all' : 'some'} results, status is ${orderStatus}`);
return hasAllResults
? { isCompleted: orderStatus === 'COMPLETED' }
: { isPartial: true };
}
private async getNewAnalysisResponseElements({
analysisGroups,
existingElements,
log,
}: {
analysisGroups: ResponseUuringuGrupp[];
existingElements: AnalysisResponseElement[];
log: Logger;
}): Promise<AnalysisResponseElementMapped[]> {
const newElements: AnalysisResponseElementMapped[] = [];
for (const analysisGroup of analysisGroups) {
log(
`[${analysisGroups.indexOf(analysisGroup) + 1}/${analysisGroups.length}] Syncing analysis group '${analysisGroup.UuringuGruppNimi}'`,
);
const elements = await getAnalysisResponseElementsForGroup({
analysisGroup,
existingElements,
log,
});
newElements.push(...elements);
}
return newElements;
}
private async hasAllAnalysisResponseElements({
analysisResponseId,
analysisOrder,
}: {
analysisResponseId: number;
analysisOrder: Pick<AnalysisOrder, 'analysis_element_ids'>;
}): Promise<boolean> {
const allOrderResponseElements = await getExistingAnalysisResponseElements({
analysisResponseId,
});
const expectedOrderResponseElements = analysisOrder.analysis_element_ids?.length ?? 0;
return allOrderResponseElements.length >= expectedOrderResponseElements;
}
}

View File

@@ -29,6 +29,19 @@ export async function getLatestMessage({
); );
} }
export async function getMedipostActionLog({
medipostPrivateMessageId,
}: {
medipostPrivateMessageId: string;
}) {
const { data: existingRecord } = await getSupabaseServerAdminClient()
.schema('medreport').from('medipost_actions')
.select('id')
.eq('medipost_private_message_id', medipostPrivateMessageId)
.single();
return existingRecord;
}
export async function upsertMedipostActionLog({ export async function upsertMedipostActionLog({
action, action,
xml, xml,
@@ -51,6 +64,10 @@ export async function upsertMedipostActionLog({
medipostExternalOrderId?: string | null; medipostExternalOrderId?: string | null;
medipostPrivateMessageId?: string | null; medipostPrivateMessageId?: string | null;
}) { }) {
if (typeof medipostPrivateMessageId !== 'string') {
throw new Error('medipostPrivateMessageId is required');
}
const recordData = { const recordData = {
action, action,
xml, xml,
@@ -62,18 +79,19 @@ export async function upsertMedipostActionLog({
medipost_private_message_id: medipostPrivateMessageId, medipost_private_message_id: medipostPrivateMessageId,
}; };
const query = getSupabaseServerAdminClient() const existingActionLog = await getMedipostActionLog({ medipostPrivateMessageId });
if (existingActionLog) {
console.info(`Medipost action log already exists for private message id: ${medipostPrivateMessageId}`);
return { medipostActionId: existingActionLog.id };
}
console.info(`Inserting medipost action log for private message id: ${medipostPrivateMessageId}`);
const { data } = await getSupabaseServerAdminClient()
.schema('medreport') .schema('medreport')
.from('medipost_actions'); .from('medipost_actions')
const { data } = medipostPrivateMessageId .insert(recordData)
? await query .select('id')
.upsert(recordData, { .throwOnError();
onConflict: 'medipost_private_message_id',
ignoreDuplicates: false,
})
.select('id')
.throwOnError()
: await query.insert(recordData).select('id').throwOnError();
const medipostActionId = data?.[0]?.id; const medipostActionId = data?.[0]?.id;
if (!medipostActionId) { if (!medipostActionId) {
@@ -84,3 +102,46 @@ export async function upsertMedipostActionLog({
return { medipostActionId }; return { medipostActionId };
} }
export async function createMedipostActionLogForError({
privateMessageXml,
medipostPrivateMessageId,
medusaOrderId,
medipostExternalOrderId,
}: {
privateMessageXml: string;
medipostPrivateMessageId: string;
medusaOrderId?: string;
medipostExternalOrderId: string;
}) {
await upsertMedipostActionLog({
action: 'sync_analysis_results_from_medipost',
xml: privateMessageXml,
hasAnalysisResults: false,
medipostPrivateMessageId,
medusaOrderId,
medipostExternalOrderId,
hasError: true,
});
}
export async function createMedipostActionLogForSuccess({
privateMessageXml,
medipostPrivateMessageId,
medusaOrderId,
medipostExternalOrderId,
}: {
privateMessageXml: string;
medipostPrivateMessageId: string;
medusaOrderId: string;
medipostExternalOrderId: string;
}) {
await upsertMedipostActionLog({
action: 'sync_analysis_results_from_medipost',
xml: privateMessageXml,
hasAnalysisResults: true,
medipostPrivateMessageId: medipostPrivateMessageId,
medusaOrderId,
medipostExternalOrderId,
});
}

View File

@@ -0,0 +1,87 @@
import axios from 'axios';
import type { GetMessageListResponse } from '~/lib/types/medipost';
import { MedipostAction } from '~/lib/types/medipost';
import type { MedipostOrderResponse } from '@/packages/shared/src/types/medipost-analysis';
import { validateMedipostResponse } from './medipostValidate.service';
import { parseXML } from '../util/xml.service';
import { getLatestMessage } from './medipostMessageBase.service';
const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!;
const PASSWORD = process.env.MEDIPOST_PASSWORD!;
const IS_ENABLED_DELETE_PRIVATE_MESSAGE =
process.env.MEDIPOST_ENABLE_DELETE_RESPONSE_PRIVATE_MESSAGE_ON_READ ===
'true';
export default class MedipostMessageClient {
public async getLatestPrivateMessageListItem({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}) {
const { data } = await axios.get<GetMessageListResponse>(BASE_URL, {
params: {
Action: MedipostAction.GetPrivateMessageList,
User: USER,
Password: PASSWORD,
},
});
if (data.code && data.code !== 0) {
throw new Error('Failed to get private message list');
}
return await getLatestMessage({
messages: data?.messages,
excludedMessageIds,
});
}
public async getPrivateMessage(messageId: string) {
const { data } = await axios.get(BASE_URL, {
params: {
Action: MedipostAction.GetPrivateMessage,
User: USER,
Password: PASSWORD,
MessageId: messageId,
},
headers: {
Accept: 'application/xml',
},
});
await validateMedipostResponse(data, { canHaveEmptyCode: true });
return {
message: (await parseXML(data)) as MedipostOrderResponse,
xml: data as string,
};
}
public async deletePrivateMessage({
medipostPrivateMessageId,
}: {
medipostPrivateMessageId: string;
}) {
if (!IS_ENABLED_DELETE_PRIVATE_MESSAGE) {
console.info(`Skipping delete private message id=${medipostPrivateMessageId} because deleting is not enabled`);
return;
}
const { data } = await axios.get(BASE_URL, {
params: {
Action: MedipostAction.DeletePrivateMessage,
User: USER,
Password: PASSWORD,
MessageId: medipostPrivateMessageId,
},
});
if (data.code && data.code !== 0) {
throw new Error(`Failed to delete private message (id: ${medipostPrivateMessageId})`);
}
}
}

View File

@@ -0,0 +1,106 @@
import type { MedipostOrderResponse, MedipostAnalysisResult } from '@/packages/shared/src/types/medipost-analysis';
interface ParsedMessageData {
analysisResult: NonNullable<MedipostAnalysisResult>;
orderNumber: string;
medipostExternalOrderId: number;
medipostExternalOrderIdRaw: string | number;
patientPersonalCode: string;
}
type ParseMessageResult =
| {
success: true;
data: ParsedMessageData;
}
| {
success: false;
reason: 'no_analysis_result' | 'invalid_order_id' | 'invalid_patient_code';
medipostExternalOrderIdRaw?: string | number;
medipostExternalOrderId?: number;
};
export default class MedipostMessageParser {
public extractAnalysisResult(
message: MedipostOrderResponse,
): ParsedMessageData['analysisResult'] | null {
return message?.Saadetis?.Vastus ?? null;
}
public extractOrderId(
message: MedipostOrderResponse,
analysisResult: ParsedMessageData['analysisResult'],
): { orderId: number; rawOrderId: string | number } | null {
const rawOrderId =
message.Saadetis?.Tellimus?.ValisTellimuseId ||
analysisResult.ValisTellimuseId;
if (!rawOrderId) {
return null;
}
const orderId = Number(rawOrderId);
if (isNaN(orderId)) {
return null;
}
return { orderId, rawOrderId };
}
public extractOrderNumber(
analysisResult: ParsedMessageData['analysisResult'],
): string {
return analysisResult.TellimuseNumber;
}
public extractPatientPersonalCode(
analysisResult: ParsedMessageData['analysisResult'],
): string | null {
return analysisResult.Patsient.Isikukood?.toString() ?? null;
}
public parseMessage(message: MedipostOrderResponse): ParseMessageResult {
const analysisResult = this.extractAnalysisResult(message);
if (!analysisResult) {
return {
success: false,
reason: 'no_analysis_result',
};
}
const orderIdResult = this.extractOrderId(message, analysisResult);
if (!orderIdResult) {
return {
success: false,
reason: 'invalid_order_id',
medipostExternalOrderIdRaw:
message.Saadetis?.Tellimus?.ValisTellimuseId ||
analysisResult.ValisTellimuseId,
};
}
const patientPersonalCode = this.extractPatientPersonalCode(analysisResult);
if (!patientPersonalCode) {
return {
success: false,
reason: 'invalid_patient_code',
medipostExternalOrderIdRaw: orderIdResult.rawOrderId,
medipostExternalOrderId: orderIdResult.orderId,
};
}
const orderNumber = this.extractOrderNumber(analysisResult);
return {
success: true,
data: {
analysisResult,
orderNumber,
medipostExternalOrderId: orderIdResult.orderId,
medipostExternalOrderIdRaw: orderIdResult.rawOrderId,
patientPersonalCode,
},
};
}
}

View File

@@ -1,12 +1,8 @@
'use server'; 'use server';
import type { PostgrestError } from '@supabase/supabase-js'; import { MedipostAction } from '@/lib/types/medipost';
import { GetMessageListResponse, MedipostAction } from '@/lib/types/medipost';
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis';
import type { import type {
MedipostOrderResponse,
ResponseUuringuGrupp, ResponseUuringuGrupp,
UuringElement, UuringElement,
} from '@/packages/shared/src/types/medipost-analysis'; } from '@/packages/shared/src/types/medipost-analysis';
@@ -14,77 +10,27 @@ import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/se
import axios from 'axios'; import axios from 'axios';
import { toArray } from '@kit/shared/utils'; import { toArray } from '@kit/shared/utils';
import { Tables } from '@kit/supabase/database';
import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element'; import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element';
import type { AnalysisOrder } from '~/lib/types/order';
import { getAccountAdmin } from '../account.service'; import { getAccountAdmin } from '../account.service';
import { getAnalyses } from '../analyses.service'; import { getAnalyses } from '../analyses.service';
import { getAnalysisElementsAdmin } from '../analysis-element.service'; import { getAnalysisElementsAdmin } from '../analysis-element.service';
import {
getExistingAnalysisResponseElements,
upsertAnalysisResponse,
upsertAnalysisResponseElement,
} from '../analysis-order.service';
import { logMedipostDispatch } from '../audit.service'; import { logMedipostDispatch } from '../audit.service';
import { getAnalysisOrder } from '../order.service'; import { getAnalysisOrder } from '../order.service';
import { parseXML } from '../util/xml.service';
import { MedipostValidationError } from './MedipostValidationError'; import { MedipostValidationError } from './MedipostValidationError';
import { import {
getLatestMessage,
upsertMedipostActionLog, upsertMedipostActionLog,
} from './medipostMessageBase.service'; } from './medipostMessageBase.service';
import { validateMedipostResponse } from './medipostValidate.service'; import { validateMedipostResponse } from './medipostValidate.service';
import { OrderedAnalysisElement, composeOrderXML } from './medipostXML.service'; import { OrderedAnalysisElement, composeOrderXML } from './medipostXML.service';
import type { Logger } from './types';
const BASE_URL = process.env.MEDIPOST_URL!; const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!; const USER = process.env.MEDIPOST_USER!;
const PASSWORD = process.env.MEDIPOST_PASSWORD!; const PASSWORD = process.env.MEDIPOST_PASSWORD!;
const RECIPIENT = process.env.MEDIPOST_RECIPIENT!; const RECIPIENT = process.env.MEDIPOST_RECIPIENT!;
const IS_ENABLED_DELETE_PRIVATE_MESSAGE =
process.env.MEDIPOST_ENABLE_DELETE_RESPONSE_PRIVATE_MESSAGE_ON_READ ===
'true';
export async function getLatestPrivateMessageListItem({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}) {
const { data } = await axios.get<GetMessageListResponse>(BASE_URL, {
params: {
Action: MedipostAction.GetPrivateMessageList,
User: USER,
Password: PASSWORD,
},
});
if (data.code && data.code !== 0) {
throw new Error('Failed to get private message list');
}
return await getLatestMessage({
messages: data?.messages,
excludedMessageIds,
});
}
const logger =
(
analysisOrder: AnalysisOrder,
externalId: string,
analysisResponseId: string,
) =>
(message: string, error?: PostgrestError | null) => {
const messageFormatted = `[${analysisOrder.id}] [${externalId}] [${analysisResponseId}] ${message}`;
if (error) {
console.info(messageFormatted, error);
} else {
console.info(messageFormatted);
}
};
export async function canCreateAnalysisResponseElement({ export async function canCreateAnalysisResponseElement({
existingElements, existingElements,
groupUuring: { groupUuring: {
@@ -101,7 +47,7 @@ export async function canCreateAnalysisResponseElement({
UuringuElement: Pick<UuringElement, 'UuringOlek' | 'UuringId'>; UuringuElement: Pick<UuringElement, 'UuringOlek' | 'UuringId'>;
}; };
responseValue: number | null; responseValue: number | null;
log: ReturnType<typeof logger>; log: Logger;
}) { }) {
const existingAnalysisResponseElement = existingElements.find( const existingAnalysisResponseElement = existingElements.find(
({ analysis_element_original_id }) => ({ analysis_element_original_id }) =>
@@ -138,7 +84,7 @@ export async function getAnalysisResponseElementsForGroup({
AnalysisResponseElement, AnalysisResponseElement,
'analysis_element_original_id' | 'status' | 'response_value' 'analysis_element_original_id' | 'status' | 'response_value'
>[]; >[];
log: ReturnType<typeof logger>; log: Logger;
}) { }) {
const groupUuringItems = toArray( const groupUuringItems = toArray(
analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'], analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'],
@@ -211,284 +157,6 @@ export async function getAnalysisResponseElementsForGroup({
return results; return results;
} }
async function getNewAnalysisResponseElements({
analysisGroups,
existingElements,
log,
}: {
analysisGroups: ResponseUuringuGrupp[];
existingElements: AnalysisResponseElement[];
log: ReturnType<typeof logger>;
}) {
const newElements: Omit<
AnalysisResponseElement,
'created_at' | 'updated_at' | 'id' | 'analysis_response_id'
>[] = [];
for (const analysisGroup of analysisGroups) {
log(
`[${analysisGroups.indexOf(analysisGroup) + 1}/${analysisGroups.length}] Syncing analysis group '${analysisGroup.UuringuGruppNimi}'`,
);
const elements = await getAnalysisResponseElementsForGroup({
analysisGroup,
existingElements,
log,
});
newElements.push(...elements);
}
return newElements;
}
async function hasAllAnalysisResponseElements({
analysisResponseId,
order,
}: {
analysisResponseId: number;
order: Pick<AnalysisOrder, 'analysis_element_ids'>;
}) {
const allOrderResponseElements = await getExistingAnalysisResponseElements({
analysisResponseId,
});
const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0;
return allOrderResponseElements.length >= expectedOrderResponseElements;
}
export async function syncPrivateMessage({
messageResponse: {
ValisTellimuseId: externalId,
TellimuseNumber: orderNumber,
TellimuseOlek,
UuringuGrupp,
},
order,
}: {
messageResponse: Pick<
NonNullable<MedipostOrderResponse['Saadetis']['Vastus']>,
'ValisTellimuseId' | 'TellimuseNumber' | 'TellimuseOlek' | 'UuringuGrupp'
>;
order: Tables<{ schema: 'medreport' }, 'analysis_orders'>;
}) {
const supabase = getSupabaseServerAdminClient();
const orderStatus = AnalysisOrderStatus[TellimuseOlek];
const log = logger(order, externalId, orderNumber);
const { data: analysisOrder } = await supabase
.schema('medreport')
.from('analysis_orders')
.select('id, user_id')
.eq('id', order.id)
.single()
.throwOnError();
const { analysisResponseId } = await upsertAnalysisResponse({
analysisOrderId: order.id,
orderNumber,
orderStatus,
userId: analysisOrder.user_id,
});
const existingElements = await getExistingAnalysisResponseElements({
analysisResponseId,
});
const analysisGroups = toArray(UuringuGrupp);
log(`Order has results for ${analysisGroups.length} analysis groups`);
const newElements = await getNewAnalysisResponseElements({
analysisGroups,
existingElements,
log,
});
for (const element of newElements) {
try {
await upsertAnalysisResponseElement({
element: {
...element,
analysis_response_id: analysisResponseId,
},
});
} catch (e) {
log(
`Failed to create order response element for response id ${analysisResponseId}, element id '${element.analysis_element_original_id}' (order id: ${order.id})`,
e as PostgrestError,
);
}
}
return (await hasAllAnalysisResponseElements({ analysisResponseId, order }))
? { isCompleted: orderStatus === 'COMPLETED' }
: { isPartial: true };
}
export async function readPrivateMessageResponse({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}): Promise<{
messageId: string | null;
hasAnalysisResponse: boolean;
hasPartialAnalysisResponse: boolean;
hasFullAnalysisResponse: boolean;
medusaOrderId: string | undefined;
analysisOrderId: number | undefined;
}> {
let messageId: string | null = null;
let hasAnalysisResponse = false;
let hasPartialAnalysisResponse = false;
let hasFullAnalysisResponse = false;
let medusaOrderId: string | undefined = undefined;
let analysisOrderId: number | undefined = undefined;
try {
const privateMessage = await getLatestPrivateMessageListItem({
excludedMessageIds,
});
messageId = privateMessage?.messageId ?? null;
if (!privateMessage || !messageId) {
return {
messageId: null,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
analysisOrderId: undefined,
};
}
const { messageId: privateMessageId } = privateMessage;
const { message: privateMessageContent, xml: privateMessageXml } =
await getPrivateMessage(privateMessageId);
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
const medipostExternalOrderId =
privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId ||
messageResponse?.ValisTellimuseId;
const patientPersonalCode = messageResponse?.Patsient.Isikukood?.toString();
analysisOrderId = Number(medipostExternalOrderId);
const hasInvalidOrderId = isNaN(analysisOrderId);
if (hasInvalidOrderId || !messageResponse || !patientPersonalCode) {
console.log({
privateMessageContent,
saadetis: privateMessageContent?.Saadetis,
messageResponse,
});
console.error(
`Invalid order id or message response or patient personal code, medipostExternalOrderId=${medipostExternalOrderId}, privateMessageId=${privateMessageId}, patientPersonalCode=${patientPersonalCode}`,
);
await upsertMedipostActionLog({
action: 'sync_analysis_results_from_medipost',
xml: privateMessageXml,
hasAnalysisResults: false,
medipostPrivateMessageId: privateMessageId,
medusaOrderId,
medipostExternalOrderId,
hasError: true,
});
return {
messageId,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: hasInvalidOrderId ? undefined : medusaOrderId,
analysisOrderId: hasInvalidOrderId ? undefined : analysisOrderId,
};
}
let analysisOrder: AnalysisOrder;
try {
analysisOrder = await getAnalysisOrder({ analysisOrderId });
medusaOrderId = analysisOrder.medusa_order_id;
} catch {
if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) {
await deletePrivateMessage(privateMessageId);
}
throw new Error(
`No analysis order found for Medipost message ValisTellimuseId=${medipostExternalOrderId}`,
);
}
const orderPerson = await getAccountAdmin({
primaryOwnerUserId: analysisOrder.user_id,
});
if (orderPerson.personal_code !== patientPersonalCode) {
throw new Error(
`Order person personal code does not match Medipost message Patsient.Isikukood=${patientPersonalCode}, orderPerson.personal_code=${orderPerson.personal_code}`,
);
}
const status = await syncPrivateMessage({
messageResponse,
order: analysisOrder,
});
console.info(
`Successfully synced analysis results from Medipost message privateMessageId=${privateMessageId}`,
);
await upsertMedipostActionLog({
action: 'sync_analysis_results_from_medipost',
xml: privateMessageXml,
hasAnalysisResults: true,
medipostPrivateMessageId: privateMessageId,
medusaOrderId,
medipostExternalOrderId,
});
if (status.isPartial) {
await createUserAnalysesApi(
getSupabaseServerAdminClient(),
).updateAnalysisOrderStatus({
medusaOrderId,
orderStatus: 'PARTIAL_ANALYSIS_RESPONSE',
});
hasAnalysisResponse = true;
hasPartialAnalysisResponse = true;
} else if (status.isCompleted) {
await createUserAnalysesApi(
getSupabaseServerAdminClient(),
).updateAnalysisOrderStatus({
medusaOrderId,
orderStatus: 'FULL_ANALYSIS_RESPONSE',
});
if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) {
await deletePrivateMessage(privateMessageId);
}
hasAnalysisResponse = true;
hasFullAnalysisResponse = true;
}
} catch (e) {
console.warn(
`Failed to process private message id=${messageId}, message=${(e as Error).message}`,
);
}
return {
messageId,
hasAnalysisResponse,
hasPartialAnalysisResponse,
hasFullAnalysisResponse,
medusaOrderId,
analysisOrderId,
};
}
export async function deletePrivateMessage(messageId: string) {
const { data } = await axios.get(BASE_URL, {
params: {
Action: MedipostAction.DeletePrivateMessage,
User: USER,
Password: PASSWORD,
MessageId: messageId,
},
});
if (data.code && data.code !== 0) {
throw new Error(`Failed to delete private message (id: ${messageId})`);
}
}
export async function sendPrivateMessage(messageXml: string) { export async function sendPrivateMessage(messageXml: string) {
const body = new FormData(); const body = new FormData();
body.append('Action', MedipostAction.SendPrivateMessage); body.append('Action', MedipostAction.SendPrivateMessage);
@@ -508,27 +176,6 @@ export async function sendPrivateMessage(messageXml: string) {
await validateMedipostResponse(data); await validateMedipostResponse(data);
} }
export async function getPrivateMessage(messageId: string) {
const { data } = await axios.get(BASE_URL, {
params: {
Action: MedipostAction.GetPrivateMessage,
User: USER,
Password: PASSWORD,
MessageId: messageId,
},
headers: {
Accept: 'application/xml',
},
});
await validateMedipostResponse(data, { canHaveEmptyCode: true });
return {
message: (await parseXML(data)) as MedipostOrderResponse,
xml: data as string,
};
}
export async function sendOrderToMedipost({ export async function sendOrderToMedipost({
medusaOrderId, medusaOrderId,
orderedAnalysisElements, orderedAnalysisElements,
@@ -597,6 +244,7 @@ export async function sendOrderToMedipost({
medusaOrderId, medusaOrderId,
responseXml: e.response, responseXml: e.response,
hasError: true, hasError: true,
medipostPrivateMessageId: `send-order-to-medipost-${Date.now()}`,
}); });
} else { } else {
console.error( console.error(
@@ -613,6 +261,7 @@ export async function sendOrderToMedipost({
hasAnalysisResults: false, hasAnalysisResults: false,
medusaOrderId, medusaOrderId,
hasError: true, hasError: true,
medipostPrivateMessageId: `send-order-to-medipost-${Date.now()}`,
}); });
} }
@@ -631,6 +280,7 @@ export async function sendOrderToMedipost({
xml: orderXml, xml: orderXml,
hasAnalysisResults: false, hasAnalysisResults: false,
medusaOrderId, medusaOrderId,
medipostPrivateMessageId: `send-order-to-medipost-${Date.now()}`,
}); });
await createUserAnalysesApi( await createUserAnalysesApi(
getSupabaseServerAdminClient(), getSupabaseServerAdminClient(),

View File

@@ -0,0 +1,223 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { AnalysisOrder } from "~/lib/types/order";
import { createUserAnalysesApi } from "@/packages/features/user-analyses/src/server/api";
import { getAnalysisOrder } from "../order.service";
import { createMedipostActionLogForError, createMedipostActionLogForSuccess, getMedipostActionLog } from "./medipostMessageBase.service";
import type { Logger } from './types';
import MedipostMessageClient from './medipostMessageClient.service';
import MedipostMessageParser from './medipostMessageParser.service';
import MedipostAnalysisResultService from './medipostAnalysisResult.service';
import { validateOrderPerson } from "./medipostValidate.service";
interface IPrivateMessageSyncResult {
messageId: string | null;
hasAnalysisResponse: boolean;
hasPartialAnalysisResponse: boolean;
hasFullAnalysisResponse: boolean;
medusaOrderId: string | undefined;
analysisOrderId: number | undefined;
}
const NO_RESULT: IPrivateMessageSyncResult = {
messageId: null,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
analysisOrderId: undefined,
};
export default class MedipostPrivateMessageSync {
private readonly client: SupabaseClient<Database>;
private readonly userAnalysesApi: ReturnType<typeof createUserAnalysesApi>;
private readonly messageClient: MedipostMessageClient;
private readonly messageParser: MedipostMessageParser;
private readonly analysisResultService: MedipostAnalysisResultService;
private loggerContext: {
analysisOrderId?: number;
orderNumber?: string;
medipostPrivateMessageId?: string;
} = {};
constructor() {
this.client = getSupabaseServerAdminClient();
this.userAnalysesApi = createUserAnalysesApi(this.client);
this.messageClient = new MedipostMessageClient();
this.messageParser = new MedipostMessageParser();
this.analysisResultService = new MedipostAnalysisResultService();
}
public async handleNextPrivateMessage({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}): Promise<IPrivateMessageSyncResult> {
let medipostPrivateMessageId: string | null = null;
let hasAnalysisResponse = false;
let hasPartialAnalysisResponse = false;
let hasFullAnalysisResponse = false;
let medusaOrderId: string | undefined = undefined;
let medipostExternalOrderId: number | undefined = undefined;
try {
const privateMessage = await this.messageClient.getLatestPrivateMessageListItem({
excludedMessageIds,
});
medipostPrivateMessageId = privateMessage?.messageId ?? null;
if (!medipostPrivateMessageId) {
return NO_RESULT;
}
this.loggerContext.medipostPrivateMessageId = medipostPrivateMessageId;
if (await getMedipostActionLog({ medipostPrivateMessageId })) {
this.logger()(`Medipost action log already exists for private message`);
return { ...NO_RESULT, messageId: medipostPrivateMessageId };
}
const { message: privateMessageContent, xml: privateMessageXml } =
await this.messageClient.getPrivateMessage(medipostPrivateMessageId);
const parseResult = this.messageParser.parseMessage(privateMessageContent);
if (!parseResult.success) {
const createErrorLog = async () => createMedipostActionLogForError({
privateMessageXml,
medipostPrivateMessageId: medipostPrivateMessageId!,
medipostExternalOrderId: parseResult.medipostExternalOrderIdRaw?.toString() ?? '',
});
switch (parseResult.reason) {
case 'no_analysis_result':
console.info(`Missing results in private message, id=${medipostPrivateMessageId}`);
break;
case 'invalid_order_id':
console.error(`Invalid order id in private message, id=${medipostPrivateMessageId}`);
await createErrorLog();
break;
case 'invalid_patient_code':
console.error(`Invalid patient personal code in private message, id=${medipostPrivateMessageId}`);
await createErrorLog();
break;
}
return {
...NO_RESULT,
messageId: medipostPrivateMessageId,
analysisOrderId: parseResult.medipostExternalOrderId,
};
}
const {
analysisResult: analysisResultResponse,
orderNumber,
medipostExternalOrderIdRaw,
patientPersonalCode,
} = parseResult.data;
this.loggerContext.orderNumber = orderNumber;
medipostExternalOrderId = parseResult.data.medipostExternalOrderId;
this.loggerContext.analysisOrderId = medipostExternalOrderId;
let analysisOrder: AnalysisOrder;
try {
this.logger()(`Getting analysis order for message`);
analysisOrder = await getAnalysisOrder({ analysisOrderId: medipostExternalOrderId });
medusaOrderId = analysisOrder.medusa_order_id;
} catch (e) {
this.logger()("Get analysis order error", "error", e as Error);
await this.messageClient.deletePrivateMessage({ medipostPrivateMessageId });
throw new Error(
`No analysis order found for Medipost message ValisTellimuseId=${medipostExternalOrderIdRaw}`,
);
}
await validateOrderPerson({ analysisOrder, patientPersonalCode });
this.logger()('Storing analysis results');
const result = await this.analysisResultService.storeAnalysisResult({
messageResponse: analysisResultResponse,
analysisOrder,
log: this.logger(),
});
this.logger()('Creating medipost action log for success');
await createMedipostActionLogForSuccess({
privateMessageXml,
medipostPrivateMessageId,
medusaOrderId,
medipostExternalOrderId: medipostExternalOrderIdRaw.toString(),
});
if (result.isPartial) {
this.logger()('Updating analysis order status to PARTIAL_ANALYSIS_RESPONSE');
await this.userAnalysesApi.updateAnalysisOrderStatus({
medusaOrderId,
orderStatus: 'PARTIAL_ANALYSIS_RESPONSE',
});
hasAnalysisResponse = true;
hasPartialAnalysisResponse = true;
} else if (result.isCompleted) {
this.logger()('Updating analysis order status to FULL_ANALYSIS_RESPONSE');
await this.userAnalysesApi.updateAnalysisOrderStatus({
medusaOrderId,
orderStatus: 'FULL_ANALYSIS_RESPONSE',
});
await this.messageClient.deletePrivateMessage({ medipostPrivateMessageId });
hasAnalysisResponse = true;
hasFullAnalysisResponse = true;
}
this.logger()('Sending analysis results notification');
await this.userAnalysesApi.sendAnalysisResultsNotification({
hasFullAnalysisResponse,
hasPartialAnalysisResponse,
analysisOrderId: medipostExternalOrderId,
});
this.logger()('Successfully synced analysis results');
} catch (e) {
console.warn(
`Failed to process private message id=${medipostPrivateMessageId}, message=${(e as Error).message}`,
);
} finally {
this.clearLoggerContext();
}
return {
messageId: medipostPrivateMessageId,
hasAnalysisResponse,
hasPartialAnalysisResponse,
hasFullAnalysisResponse,
medusaOrderId,
analysisOrderId: medipostExternalOrderId,
};
}
private logger(): Logger {
const { analysisOrderId, orderNumber, medipostPrivateMessageId } = this.loggerContext;
return (message, level = 'info', error) => {
const messageFormatted = `[${analysisOrderId ?? ''}] [${orderNumber ?? '-'}] [${medipostPrivateMessageId ?? '-'}] ${message}`;
const logFn = console[level];
if (error) {
logFn(messageFormatted, error);
} else {
logFn(messageFormatted);
}
};
}
private clearLoggerContext(): void {
this.loggerContext = {};
}
}

View File

@@ -1,9 +1,11 @@
'use server'; 'use server';
import type { IMedipostResponseXMLBase } from '@/packages/shared/src/types/medipost-analysis'; import type { IMedipostResponseXMLBase } from '@/packages/shared/src/types/medipost-analysis';
import type { AnalysisOrder } from '~/lib/types/order';
import { parseXML } from '../util/xml.service'; import { parseXML } from '../util/xml.service';
import { MedipostValidationError } from './MedipostValidationError'; import { MedipostValidationError } from './MedipostValidationError';
import { getAccountAdmin } from '../account.service';
export async function validateMedipostResponse( export async function validateMedipostResponse(
response: string, response: string,
@@ -24,3 +26,20 @@ export async function validateMedipostResponse(
throw new MedipostValidationError(response); throw new MedipostValidationError(response);
} }
} }
export async function validateOrderPerson({
analysisOrder,
patientPersonalCode,
}: {
analysisOrder: AnalysisOrder;
patientPersonalCode: string;
}) {
const orderPerson = await getAccountAdmin({
primaryOwnerUserId: analysisOrder.user_id,
});
if (orderPerson.personal_code !== patientPersonalCode) {
throw new Error(
`Order person personal code does not match Medipost message Patsient.Isikukood=${patientPersonalCode}, orderPerson.personal_code=${orderPerson.personal_code}`,
);
}
}

View File

@@ -0,0 +1 @@
export type Logger = (message: string, level?: 'info' | 'error' | 'warn', error?: Error | null) => void;

View File

@@ -1,6 +1,5 @@
import type { StoreOrder } from '@medusajs/types'; import type { StoreOrder } from '@medusajs/types';
import type { Tables } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -72,6 +71,7 @@ export async function getAnalysisOrder({
const { data: order, error } = await query.single(); const { data: order, error } = await query.single();
if (error) { if (error) {
console.error("Get analysis order error", error);
throw new Error( throw new Error(
`Failed to get order by medusaOrderId=${medusaOrderId} or analysisOrderId=${analysisOrderId}, message=${error.message}, data=${JSON.stringify(order)}`, `Failed to get order by medusaOrderId=${medusaOrderId} or analysisOrderId=${analysisOrderId}, message=${error.message}, data=${JSON.stringify(order)}`,
); );
@@ -82,7 +82,7 @@ export async function getAnalysisOrder({
export async function getAnalysisOrders({ export async function getAnalysisOrders({
orderStatus, orderStatus,
}: { }: {
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status']; orderStatus?: AnalysisOrder['status'];
} = {}) { } = {}) {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
@@ -111,7 +111,7 @@ export async function getAnalysisOrdersAdmin({
orderStatus, orderStatus,
medusaOrderId, medusaOrderId,
}: { }: {
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status']; orderStatus?: AnalysisOrder['status'];
medusaOrderId?: string | null; medusaOrderId?: string | null;
} = {}) { } = {}) {
const query = getSupabaseServerAdminClient() const query = getSupabaseServerAdminClient()

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,23 @@ export const AnalysisResponsesSchema = z.object({
}); });
export type AnalysisResponses = z.infer<typeof AnalysisResponsesSchema>; 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({ export const AnalysisResponseSchema = z.object({
id: z.number(), id: z.number(),
analysis_response_id: z.number(), analysis_response_id: z.number(),
@@ -69,6 +86,7 @@ export const AnalysisResponseSchema = z.object({
analysis_name: z.string().nullable(), analysis_name: z.string().nullable(),
analysis_responses: AnalysisResponsesSchema, analysis_responses: AnalysisResponsesSchema,
comment: z.string().nullable(), comment: z.string().nullable(),
nestedElements: z.array(NestedAnalysisElementSchema).optional(),
latestPreviousAnalysis: z latestPreviousAnalysis: z
.object({ .object({
id: z.number(), id: z.number(),
@@ -86,6 +104,7 @@ export const AnalysisResponseSchema = z.object({
updated_at: z.string().nullable(), updated_at: z.string().nullable(),
analysis_name: z.string().nullable(), analysis_name: z.string().nullable(),
comment: z.string().nullable(), comment: z.string().nullable(),
nestedElements: z.array(NestedAnalysisElementSchema).optional(),
}) })
.optional() .optional()
.nullable(), .nullable(),

View File

@@ -5,12 +5,16 @@ import { isBefore } from 'date-fns';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates'; import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getLogger } from '@kit/shared/logger'; 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 { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createUserAnalysesApi } from '@kit/user-analyses/api'; import { createUserAnalysesApi } from '@kit/user-analyses/api';
import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service'; 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 { import {
AnalysisResponseBase, AnalysisResponseBase,
DoctorAnalysisFeedbackTable, DoctorAnalysisFeedbackTable,
@@ -20,6 +24,63 @@ import {
} from '../schema/doctor-analysis.schema'; } from '../schema/doctor-analysis.schema';
import { ErrorReason } from '../schema/error.type'; 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[]) { async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) {
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerClient();
@@ -186,12 +247,13 @@ export async function getUserInProgressResponses({
`, `,
{ count: 'exact' }, { count: 'exact' },
) )
.neq('status', 'ON_HOLD') .eq('order_status', 'COMPLETED')
.in('analysis_order_id', analysisOrderIds) .in('analysis_order_id', analysisOrderIds)
.range(offset, offset + pageSize - 1) .range(offset, offset + pageSize - 1)
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) { if (error) {
console.error("Failed to get analysis responses", error);
throw new Error('Something went wrong'); throw new Error('Something went wrong');
} }
@@ -388,7 +450,8 @@ export async function getAnalysisResultsForDoctor(
.from(`analysis_response_elements`) .from(`analysis_response_elements`)
.select( .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); .eq('analysis_response_id', analysisResponseId);
@@ -404,8 +467,14 @@ export async function getAnalysisResultsForDoctor(
const medusaOrderId = const medusaOrderId =
firstAnalysisResponse?.analysis_responses?.analysis_order?.medusa_order_id; firstAnalysisResponse?.analysis_responses?.analysis_order?.medusa_order_id;
if (!analysisResponsesData?.length || !userId || !medusaOrderId) { if (!analysisResponsesData?.length) {
throw new Error('Failed to retrieve full analysis data.'); 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( const responseElementAnalysisElementOriginalIds = analysisResponsesData.map(
@@ -446,7 +515,8 @@ export async function getAnalysisResultsForDoctor(
*, *,
analysis_responses!inner( analysis_responses!inner(
user_id user_id
) ),
original_response_element
`, `,
) )
.in( .in(
@@ -485,8 +555,14 @@ export async function getAnalysisResultsForDoctor(
preferred_locale, preferred_locale,
} = accountWithParams[0]; } = accountWithParams[0];
// Parse nested elements for current and previous analyses
const analysisResponseElementsWithPreviousData = []; const analysisResponseElementsWithPreviousData = [];
for (const analysisResponseElement of analysisResponsesData) { for (const analysisResponseElement of analysisResponsesData) {
const nestedElements = parseNestedElements(
analysisResponseElement.original_response_element as ResponseUuring,
Number(analysisResponseElement.status),
);
const latestPreviousAnalysis = previousAnalyses.find( const latestPreviousAnalysis = previousAnalyses.find(
({ analysis_element_original_id, response_time }) => { ({ analysis_element_original_id, response_time }) => {
if (response_time && analysisResponseElement.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({ analysisResponseElementsWithPreviousData.push({
...analysisResponseElement, ...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 { return {
analysisResponse: analysisResponseElementsWithPreviousData, analysisResponse: analysisResponseElementsWithPreviousData,
order: { order: {

View File

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

View File

@@ -12,7 +12,7 @@ export function ButtonTooltip({
content, content,
className, className,
}: { }: {
content?: string; content?: string | React.ReactNode;
className?: string; className?: string;
}) { }) {
if (!content) return null; if (!content) return null;

View File

@@ -130,3 +130,4 @@ export type MedipostOrderResponse = IMedipostResponseXMLBase & {
}; };
}; };
}; };
export type MedipostAnalysisResult = MedipostOrderResponse['Saadetis']['Vastus'];

View File

@@ -65,7 +65,9 @@ export function useAuthChangeListener({
return; return;
} }
window.location.reload(); // Redirect to home instead of reloading to avoid state mismatch errors
// during the transition
window.location.assign('/');
} }
}); });

View File

@@ -24,5 +24,6 @@
"view": "View results", "view": "View results",
"notification": { "notification": {
"body": "You have new analysis results" "body": "You have new analysis results"
} },
"orderCreatedAt": "Order created at: {{createdAt}}"
} }

View File

@@ -24,5 +24,6 @@
"view": "Vaata tulemusi", "view": "Vaata tulemusi",
"notification": { "notification": {
"body": "Teil on valmis uued analüüsi tulemused" "body": "Teil on valmis uued analüüsi tulemused"
} },
"orderCreatedAt": "Tellimus loodud: {{createdAt}}"
} }

View File

@@ -23,5 +23,6 @@
"orderTitle": "Заказ {{orderNumber}}", "orderTitle": "Заказ {{orderNumber}}",
"notification": { "notification": {
"body": "Teil on valmis uued analüüsi tulemused" "body": "Teil on valmis uued analüüsi tulemused"
} },
"orderCreatedAt": "Заказ создан: {{createdAt}}"
} }