'use server'; import type { PostgrestError } from '@supabase/supabase-js'; import { GetMessageListResponse, MedipostAction } from '@/lib/types/medipost'; import { createNotificationsApi } from '@/packages/features/notifications/src/server/api'; import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; import { pathsConfig } from '@/packages/shared/src/config'; import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis'; import type { MedipostOrderResponse, ResponseUuringuGrupp, UuringElement, } from '@/packages/shared/src/types/medipost-analysis'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; import axios from 'axios'; import { toArray } from '@kit/shared/utils'; import { Tables } from '@kit/supabase/database'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; 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'; 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: { UuringuElement: { UuringOlek: status, UuringId: analysisElementOriginalId }, }, responseValue, log, }: { existingElements: Pick< AnalysisResponseElement, 'analysis_element_original_id' | 'status' | 'response_value' >[]; groupUuring: { UuringuElement: Pick; }; responseValue: number | null; log: ReturnType; }) { const existingAnalysisResponseElement = existingElements.find( ({ analysis_element_original_id }) => analysis_element_original_id === analysisElementOriginalId, ); if (!existingAnalysisResponseElement) { return true; } if (Number(existingAnalysisResponseElement.status) > status) { log( `Analysis response element id=${analysisElementOriginalId} already exists for order in higher status ${existingAnalysisResponseElement.status} than ${status}`, ); return false; } if (existingAnalysisResponseElement.response_value && !responseValue) { log( `Analysis response element id=${analysisElementOriginalId} already exists for order with response value ${existingAnalysisResponseElement.response_value} but new response has no value`, ); return false; } return true; } export async function getAnalysisResponseElementsForGroup({ analysisGroup, existingElements, log, }: { analysisGroup: Pick; existingElements: Pick< AnalysisResponseElement, 'analysis_element_original_id' | 'status' | 'response_value' >[]; log: ReturnType; }) { const groupUuringItems = toArray( analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'], ); log( `Order has results in group '${analysisGroup.UuringuGruppNimi}' for ${groupUuringItems.length} analysis elements`, ); const results: Omit< AnalysisResponseElement, 'created_at' | 'updated_at' | 'id' | 'analysis_response_id' >[] = []; for (const groupUuring of groupUuringItems) { const groupUuringElement = groupUuring.UuringuElement; const elementAnalysisResponses = toArray(groupUuringElement.UuringuVastus); const status = groupUuringElement.UuringOlek; log( `Group uuring '${analysisGroup.UuringuGruppNimi}' has status ${status}`, ); for (const response of elementAnalysisResponses) { const analysisElementOriginalId = groupUuringElement.UuringId; const vastuseVaartus = response.VastuseVaartus; const responseValue = (() => { const valueAsNumber = Number(vastuseVaartus); if (isNaN(valueAsNumber)) { return null; } return valueAsNumber; })(); if ( !(await canCreateAnalysisResponseElement({ existingElements, groupUuring, responseValue, log, })) ) { continue; } const mappedResponse = createUserAnalysesApi( getSupabaseServerAdminClient(), ).mapUuringVastus({ uuringVastus: response }); results.push({ analysis_element_original_id: analysisElementOriginalId, norm_lower: mappedResponse.normLower, norm_lower_included: mappedResponse.normLowerIncluded, norm_status: mappedResponse.normStatus, norm_upper: mappedResponse.normUpper, norm_upper_included: mappedResponse.normUpperIncluded, response_time: mappedResponse.responseTime, response_value: mappedResponse.responseValue, unit: groupUuringElement.Mootyhik ?? null, original_response_element: groupUuringElement, analysis_name: groupUuringElement.UuringNimi || groupUuringElement.KNimetus, comment: groupUuringElement.UuringuKommentaar ?? null, status: status.toString(), response_value_is_within_norm: mappedResponse.responseValueIsWithinNorm, response_value_is_negative: mappedResponse.responseValueIsNegative, }); } } 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 { t } = await createI18nServerInstance(); 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, }); let newElementsAdded = 0; for (const element of newElements) { try { await upsertAnalysisResponseElement({ element: { ...element, analysis_response_id: analysisResponseId, }, }); newElementsAdded++; } 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, ); } } log(`Added ${newElementsAdded} new elements`); if (newElementsAdded !== 0) { await createNotificationsApi(supabase).createNotification({ account_id: analysisOrder.user_id, body: t('analysis-results:notification.body'), link: `${pathsConfig.app.analysisResults}/${order.id}`, }); } 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}`, ); 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', }); if (IS_ENABLED_DELETE_PRIVATE_MESSAGE) { await deletePrivateMessage(privateMessageId); } 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); body.append('User', USER); body.append('Password', PASSWORD); body.append('Receiver', RECIPIENT); body.append('MessageType', 'Tellimus'); body.append( 'Message', new Blob([messageXml], { type: 'text/xml; charset=UTF-8', }), ); const { data } = await axios.post(BASE_URL, body); 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, }: { medusaOrderId: string; orderedAnalysisElements: OrderedAnalysisElement[]; }) { const medreportOrder = await getAnalysisOrder({ medusaOrderId }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id, }); const orderedAnalysesIds = orderedAnalysisElements .map(({ analysisId }) => analysisId) .filter(Boolean) as number[]; const orderedAnalysisElementsIds = orderedAnalysisElements .map(({ analysisElementId }) => analysisElementId) .filter(Boolean) as number[]; const analyses = await getAnalyses({ ids: orderedAnalysesIds }); if (analyses.length !== orderedAnalysesIds.length) { throw new Error( `Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`, ); } const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds, }); if (analysisElements.length !== orderedAnalysisElementsIds.length) { throw new Error( `Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`, ); } const orderXml = await composeOrderXML({ analyses, analysisElements, person: { idCode: account.personal_code!, firstName: account.name ?? '', lastName: account.last_name ?? '', phone: account.phone ?? '', }, orderId: medreportOrder.id, comment: '', }); try { await sendPrivateMessage(orderXml); } catch (e) { const isMedipostError = e instanceof MedipostValidationError; if (isMedipostError) { await logMedipostDispatch({ medusaOrderId, isSuccess: false, isMedipostError, errorMessage: e.response, }); console.error( `Failed to send order to Medipost, medusaOrderId=${medusaOrderId}, error=${e.response}`, ); await upsertMedipostActionLog({ action: 'send_order_to_medipost', xml: orderXml, hasAnalysisResults: false, medusaOrderId, responseXml: e.response, hasError: true, }); } else { console.error( `Failed to send order to Medipost, medusaOrderId=${medusaOrderId}, error=${e}`, ); await logMedipostDispatch({ medusaOrderId, isSuccess: false, isMedipostError, }); await upsertMedipostActionLog({ action: 'send_order_to_medipost', xml: orderXml, hasAnalysisResults: false, medusaOrderId, hasError: true, }); } throw e; } console.info( `Successfully sent order to Medipost, medusaOrderId=${medusaOrderId}`, ); await logMedipostDispatch({ medusaOrderId, isSuccess: true, isMedipostError: false, }); await upsertMedipostActionLog({ action: 'send_order_to_medipost', xml: orderXml, hasAnalysisResults: false, medusaOrderId, }); await createUserAnalysesApi( getSupabaseServerAdminClient(), ).updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING', }); }