From 8f32fdf08dc71f12b031ac2f124241526952020a Mon Sep 17 00:00:00 2001 From: Karli Date: Wed, 12 Nov 2025 08:51:48 +0200 Subject: [PATCH] Move medipostPrivateMessage.service to separate classes, improve logging --- app/api/job/handler/sync-analysis-results.ts | 27 +- app/api/order/medipost-test-response/route.ts | 1 + .../medipostAnalysisResult.service.ts | 142 +++++++ .../medipost/medipostMessageBase.service.ts | 83 +++- .../medipost/medipostMessageClient.service.ts | 87 +++++ .../medipost/medipostMessageParser.service.ts | 106 +++++ .../medipostPrivateMessage.service.ts | 364 +----------------- .../medipostPrivateMessageSync.service.ts | 223 +++++++++++ .../medipost/medipostValidate.service.ts | 19 + lib/services/medipost/types.ts | 1 + lib/services/order.service.ts | 6 +- .../shared/src/types/medipost-analysis.ts | 1 + 12 files changed, 670 insertions(+), 390 deletions(-) create mode 100644 lib/services/medipost/medipostAnalysisResult.service.ts create mode 100644 lib/services/medipost/medipostMessageClient.service.ts create mode 100644 lib/services/medipost/medipostMessageParser.service.ts create mode 100644 lib/services/medipost/medipostPrivateMessageSync.service.ts create mode 100644 lib/services/medipost/types.ts diff --git a/app/api/job/handler/sync-analysis-results.ts b/app/api/job/handler/sync-analysis-results.ts index 06e80d5..20c8986 100644 --- a/app/api/job/handler/sync-analysis-results.ts +++ b/app/api/job/handler/sync-analysis-results.ts @@ -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; } diff --git a/app/api/order/medipost-test-response/route.ts b/app/api/order/medipost-test-response/route.ts index 2cca87e..1098739 100644 --- a/app/api/order/medipost-test-response/route.ts +++ b/app/api/order/medipost-test-response/route.ts @@ -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) { diff --git a/lib/services/medipost/medipostAnalysisResult.service.ts b/lib/services/medipost/medipostAnalysisResult.service.ts new file mode 100644 index 0000000..e2c8cee --- /dev/null +++ b/lib/services/medipost/medipostAnalysisResult.service.ts @@ -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, + 'TellimuseNumber' | 'TellimuseOlek' | 'UuringuGrupp' + >; + analysisOrder: AnalysisOrder; + log: Logger; + }): Promise { + 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 { + 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; + }): Promise { + const allOrderResponseElements = await getExistingAnalysisResponseElements({ + analysisResponseId, + }); + const expectedOrderResponseElements = analysisOrder.analysis_element_ids?.length ?? 0; + return allOrderResponseElements.length >= expectedOrderResponseElements; + } +} diff --git a/lib/services/medipost/medipostMessageBase.service.ts b/lib/services/medipost/medipostMessageBase.service.ts index 56ad96d..dafad4e 100644 --- a/lib/services/medipost/medipostMessageBase.service.ts +++ b/lib/services/medipost/medipostMessageBase.service.ts @@ -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, + }); +} diff --git a/lib/services/medipost/medipostMessageClient.service.ts b/lib/services/medipost/medipostMessageClient.service.ts new file mode 100644 index 0000000..8bc95b5 --- /dev/null +++ b/lib/services/medipost/medipostMessageClient.service.ts @@ -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(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})`); + } + } +} diff --git a/lib/services/medipost/medipostMessageParser.service.ts b/lib/services/medipost/medipostMessageParser.service.ts new file mode 100644 index 0000000..4bba0bb --- /dev/null +++ b/lib/services/medipost/medipostMessageParser.service.ts @@ -0,0 +1,106 @@ +import type { MedipostOrderResponse, MedipostAnalysisResult } from '@/packages/shared/src/types/medipost-analysis'; + +interface ParsedMessageData { + analysisResult: NonNullable; + 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, + }, + }; + } +} diff --git a/lib/services/medipost/medipostPrivateMessage.service.ts b/lib/services/medipost/medipostPrivateMessage.service.ts index 55376ae..ee01147 100644 --- a/lib/services/medipost/medipostPrivateMessage.service.ts +++ b/lib/services/medipost/medipostPrivateMessage.service.ts @@ -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(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; }; responseValue: number | null; - log: ReturnType; + 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; + 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; -}) { - 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; -}) { - 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, - '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(), diff --git a/lib/services/medipost/medipostPrivateMessageSync.service.ts b/lib/services/medipost/medipostPrivateMessageSync.service.ts new file mode 100644 index 0000000..d1db06e --- /dev/null +++ b/lib/services/medipost/medipostPrivateMessageSync.service.ts @@ -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; + private readonly userAnalysesApi: ReturnType; + + 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 { + 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 = {}; + } + +} diff --git a/lib/services/medipost/medipostValidate.service.ts b/lib/services/medipost/medipostValidate.service.ts index 54a7bc4..f8a0870 100644 --- a/lib/services/medipost/medipostValidate.service.ts +++ b/lib/services/medipost/medipostValidate.service.ts @@ -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}`, + ); + } +} diff --git a/lib/services/medipost/types.ts b/lib/services/medipost/types.ts new file mode 100644 index 0000000..619d740 --- /dev/null +++ b/lib/services/medipost/types.ts @@ -0,0 +1 @@ +export type Logger = (message: string, level?: 'info' | 'error' | 'warn', error?: Error | null) => void; diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index a7128e3..c0c18d2 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -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() diff --git a/packages/shared/src/types/medipost-analysis.ts b/packages/shared/src/types/medipost-analysis.ts index 7ac3eac..eedc556 100644 --- a/packages/shared/src/types/medipost-analysis.ts +++ b/packages/shared/src/types/medipost-analysis.ts @@ -130,3 +130,4 @@ export type MedipostOrderResponse = IMedipostResponseXMLBase & { }; }; }; +export type MedipostAnalysisResult = MedipostOrderResponse['Saadetis']['Vastus'];