Compare commits
14 Commits
269b4c3e27
...
testing-11
| Author | SHA1 | Date | |
|---|---|---|---|
| d643788919 | |||
| 96fbb71c87 | |||
| bf5546c48b | |||
| 44ca92ac91 | |||
| 1b17dd845a | |||
| 2c0634f444 | |||
| 5757c44e12 | |||
| 487d604e19 | |||
| f7fbbd2352 | |||
| a77e2a7f70 | |||
| b216f7b211 | |||
| 5ef7f58f5d | |||
| 2cb6a0343a | |||
| 8f32fdf08d |
@@ -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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
12
lib/services/analysis-response.service.ts
Normal file
12
lib/services/analysis-response.service.ts
Normal 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;
|
||||||
|
}
|
||||||
142
lib/services/medipost/medipostAnalysisResult.service.ts
Normal file
142
lib/services/medipost/medipostAnalysisResult.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
87
lib/services/medipost/medipostMessageClient.service.ts
Normal file
87
lib/services/medipost/medipostMessageClient.service.ts
Normal 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})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/services/medipost/medipostMessageParser.service.ts
Normal file
106
lib/services/medipost/medipostMessageParser.service.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
|||||||
223
lib/services/medipost/medipostPrivateMessageSync.service.ts
Normal file
223
lib/services/medipost/medipostPrivateMessageSync.service.ts
Normal 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 = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1
lib/services/medipost/types.ts
Normal file
1
lib/services/medipost/types.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export type Logger = (message: string, level?: 'info' | 'error' | 'warn', error?: Error | null) => void;
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -130,3 +130,4 @@ export type MedipostOrderResponse = IMedipostResponseXMLBase & {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
export type MedipostAnalysisResult = MedipostOrderResponse['Saadetis']['Vastus'];
|
||||||
|
|||||||
@@ -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('/');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user