Move medipostPrivateMessage.service to separate classes, improve logging

This commit is contained in:
2025-11-12 08:51:48 +02:00
parent 0878b5d1bd
commit 8f32fdf08d
12 changed files with 670 additions and 390 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,8 @@
'use server';
import type { PostgrestError } from '@supabase/supabase-js';
import { GetMessageListResponse, MedipostAction } from '@/lib/types/medipost';
import { MedipostAction } from '@/lib/types/medipost';
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis';
import type {
MedipostOrderResponse,
ResponseUuringuGrupp,
UuringElement,
} from '@/packages/shared/src/types/medipost-analysis';
@@ -14,77 +10,27 @@ import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/se
import axios from 'axios';
import { toArray } from '@kit/shared/utils';
import { Tables } from '@kit/supabase/database';
import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element';
import type { AnalysisOrder } from '~/lib/types/order';
import { getAccountAdmin } from '../account.service';
import { getAnalyses } from '../analyses.service';
import { getAnalysisElementsAdmin } from '../analysis-element.service';
import {
getExistingAnalysisResponseElements,
upsertAnalysisResponse,
upsertAnalysisResponseElement,
} from '../analysis-order.service';
import { logMedipostDispatch } from '../audit.service';
import { getAnalysisOrder } from '../order.service';
import { parseXML } from '../util/xml.service';
import { MedipostValidationError } from './MedipostValidationError';
import {
getLatestMessage,
upsertMedipostActionLog,
} from './medipostMessageBase.service';
import { validateMedipostResponse } from './medipostValidate.service';
import { OrderedAnalysisElement, composeOrderXML } from './medipostXML.service';
import type { Logger } from './types';
const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!;
const PASSWORD = process.env.MEDIPOST_PASSWORD!;
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({
existingElements,
groupUuring: {
@@ -101,7 +47,7 @@ export async function canCreateAnalysisResponseElement({
UuringuElement: Pick<UuringElement, 'UuringOlek' | 'UuringId'>;
};
responseValue: number | null;
log: ReturnType<typeof logger>;
log: Logger;
}) {
const existingAnalysisResponseElement = existingElements.find(
({ analysis_element_original_id }) =>
@@ -138,7 +84,7 @@ export async function getAnalysisResponseElementsForGroup({
AnalysisResponseElement,
'analysis_element_original_id' | 'status' | 'response_value'
>[];
log: ReturnType<typeof logger>;
log: Logger;
}) {
const groupUuringItems = toArray(
analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'],
@@ -211,284 +157,6 @@ export async function getAnalysisResponseElementsForGroup({
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) {
const body = new FormData();
body.append('Action', MedipostAction.SendPrivateMessage);
@@ -508,27 +176,6 @@ export async function sendPrivateMessage(messageXml: string) {
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({
medusaOrderId,
orderedAnalysisElements,
@@ -597,6 +244,7 @@ export async function sendOrderToMedipost({
medusaOrderId,
responseXml: e.response,
hasError: true,
medipostPrivateMessageId: `send-order-to-medipost-${Date.now()}`,
});
} else {
console.error(
@@ -613,6 +261,7 @@ export async function sendOrderToMedipost({
hasAnalysisResults: false,
medusaOrderId,
hasError: true,
medipostPrivateMessageId: `send-order-to-medipost-${Date.now()}`,
});
}
@@ -631,6 +280,7 @@ export async function sendOrderToMedipost({
xml: orderXml,
hasAnalysisResults: false,
medusaOrderId,
medipostPrivateMessageId: `send-order-to-medipost-${Date.now()}`,
});
await createUserAnalysesApi(
getSupabaseServerAdminClient(),

View File

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

View File

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

View File

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

View File

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

View File

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