'use server'; import { SupabaseClient, createClient as createCustomClient, } from '@supabase/supabase-js'; import { getAnalysisGroup, getClientInstitution, getClientPerson, getConfidentiality, getPais, getPatient, getProviderInstitution, getSpecimen, } from '@/lib/templates/medipost-order'; import { SyncStatus } from '@/lib/types/audit'; import { AnalysisOrderStatus, GetMessageListResponse, IMedipostResponseXMLBase, MaterjalideGrupp, MedipostAction, MedipostOrderResponse, MedipostPublicMessageResponse, Message, ResponseUuringuGrupp, UuringuGrupp, } from '@/lib/types/medipost'; import { toArray } from '@/lib/utils'; import axios from 'axios'; import { XMLParser } from 'fast-xml-parser'; import { uniqBy } from 'lodash'; import { Tables } from '@kit/supabase/database'; import { createAnalysisGroup } from './analysis-group.service'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; import { getOrder, updateOrderStatus } from './order.service'; import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service'; import { getAnalyses } from './analyses.service'; import { getAccountAdmin } from './account.service'; import { StoreOrder } from '@medusajs/types'; import { listProducts } from '@lib/data/products'; import { listRegions } from '@lib/data/regions'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; 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 ANALYSIS_PACKAGE_HANDLE_PREFIX = 'analysis-package-'; function parseXML(xml: string) { const parser = new XMLParser({ ignoreAttributes: false }); return parser.parse(xml); } export async function validateMedipostResponse(response: string, { canHaveEmptyCode = false }: { canHaveEmptyCode?: boolean } = {}) { const parsed: IMedipostResponseXMLBase = parseXML(response); const code = parsed.ANSWER?.CODE; if (canHaveEmptyCode) { if (code && code !== 0) { console.error("Bad response", response); throw new Error(`Medipost response is invalid`); } return; } if (typeof code !== 'number' || (code !== 0 && !canHaveEmptyCode)) { console.error("Bad response", response); throw new Error(`Medipost response is invalid`); } } export async function getMessages() { try { const publicMessage = await getLatestPublicMessageListItem(); if (!publicMessage) { return null; } //Teenused tuleb mappida kokku MedReport teenustega. alusel return getPublicMessage(publicMessage.messageId); } catch (error) { console.error(error); } } export async function getLatestPublicMessageListItem() { const { data } = await axios.get(BASE_URL, { params: { Action: MedipostAction.GetPublicMessageList, User: USER, Password: PASSWORD, Sender: 'syndev', // LastChecked (date+time) can be used here to get only messages since the last check - add when cron is created // MessageType check only for messages of certain type }, }); if (data.code && data.code !== 0) { throw new Error('Failed to get public message list'); } return getLatestMessage({ messages: data?.messages }); } export async function getPublicMessage(messageId: string) { const { data } = await axios.get(BASE_URL, { params: { Action: MedipostAction.GetPublicMessage, User: USER, Password: PASSWORD, MessageId: messageId, }, headers: { Accept: 'application/xml', }, }); await validateMedipostResponse(data); return parseXML(data) as MedipostPublicMessageResponse; } 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 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 getLatestMessage({ messages: data?.messages, excludedMessageIds }); } 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 parseXML(data) as MedipostOrderResponse; } 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 readPrivateMessageResponse({ excludedMessageIds, }: { excludedMessageIds: string[]; }) { let messageIdErrored: string | null = null; let messageIdProcessed: string | null = null; try { const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds }); if (!privateMessage) { throw new Error(`No private message found`); } messageIdErrored = privateMessage.messageId; if (!messageIdErrored) { throw new Error(`No message id found`); } const privateMessageContent = await getPrivateMessage( privateMessage.messageId, ); const messageResponse = privateMessageContent?.Saadetis?.Vastus; const medusaOrderId = privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId || messageResponse?.ValisTellimuseId; if (!messageResponse) { throw new Error(`Private message response has no results yet for order=${medusaOrderId}`); } let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; try { order = await getOrder({ medusaOrderId }); } catch (e) { await deletePrivateMessage(privateMessage.messageId); throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`); } const status = await syncPrivateMessage({ messageResponse, order }); if (status.isPartial) { await updateOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' }); messageIdProcessed = privateMessage.messageId; } else if (status.isCompleted) { await updateOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); await deletePrivateMessage(privateMessage.messageId); messageIdProcessed = privateMessage.messageId; } } catch (e) { console.warn(`Failed to process private message id=${messageIdErrored}, message=${(e as Error).message}`); } return { messageIdErrored, messageIdProcessed }; } async function saveAnalysisGroup( analysisGroup: UuringuGrupp, supabase: SupabaseClient, ) { const analysisGroupId = await createAnalysisGroup({ id: analysisGroup.UuringuGruppId, name: analysisGroup.UuringuGruppNimi, order: analysisGroup.UuringuGruppJarjekord, }); const analysisGroupCodes = toArray(analysisGroup.Kood); const codes: Partial>[] = analysisGroupCodes.map((kood) => ({ hk_code: kood.HkKood, hk_code_multiplier: kood.HkKoodiKordaja, coefficient: kood.Koefitsient, price: kood.Hind, analysis_group_id: analysisGroupId, })); const analysisGroupItems = toArray(analysisGroup.Uuring); for (const item of analysisGroupItems) { const analysisElement = item.UuringuElement; const { data: insertedAnalysisElement, error } = await supabase .schema('medreport') .from('analysis_elements') .upsert( { analysis_id_oid: analysisElement.UuringIdOID, analysis_id_original: analysisElement.UuringId, tehik_short_loinc: analysisElement.TLyhend, tehik_loinc_name: analysisElement.KNimetus, analysis_name_lab: analysisElement.UuringNimi, order: analysisElement.Jarjekord, parent_analysis_group_id: analysisGroupId, material_groups: toArray(item.MaterjalideGrupp), }, { onConflict: 'analysis_id_original', ignoreDuplicates: false }, ) .select('id'); if (error || !insertedAnalysisElement[0]?.id) { throw new Error( `Failed to insert analysis element (id: ${analysisElement.UuringId}), error: ${error?.message}`, ); } const insertedAnalysisElementId = insertedAnalysisElement[0].id; if (analysisElement.Kood) { const analysisElementCodes = toArray(analysisElement.Kood); codes.push( ...analysisElementCodes.map((kood) => ({ hk_code: kood.HkKood, hk_code_multiplier: kood.HkKoodiKordaja, coefficient: kood.Koefitsient, price: kood.Hind, analysis_element_id: insertedAnalysisElementId, })), ); } const analyses = analysisElement.UuringuElement; if (analyses?.length) { for (const analysis of analyses) { const { data: insertedAnalysis, error } = await supabase .schema('medreport') .from('analyses') .upsert( { analysis_id_oid: analysis.UuringIdOID, analysis_id_original: analysis.UuringId, tehik_short_loinc: analysis.TLyhend, tehik_loinc_name: analysis.KNimetus, analysis_name_lab: analysis.UuringNimi, order: analysis.Jarjekord, parent_analysis_element_id: insertedAnalysisElementId, }, { onConflict: 'analysis_id_original', ignoreDuplicates: false }, ) .select('id'); if (error || !insertedAnalysis[0]?.id) { throw new Error( `Failed to insert analysis (id: ${analysis.UuringId}) error: ${error?.message}`, ); } const insertedAnalysisId = insertedAnalysis[0].id; if (analysisElement.Kood) { const analysisCodes = toArray(analysis.Kood); codes.push( ...analysisCodes.map((kood) => ({ hk_code: kood.HkKood, hk_code_multiplier: kood.HkKoodiKordaja, coefficient: kood.Koefitsient, price: kood.Hind, analysis_id: insertedAnalysisId, })), ); } } } } const { error: codesError } = await supabase .schema('medreport') .from('codes') .upsert(codes, { ignoreDuplicates: false }); if (codesError?.code) { throw new Error( `Failed to insert codes (analysis group id: ${analysisGroup.UuringuGruppId})`, ); } } export async function syncPublicMessage( message?: MedipostPublicMessageResponse | null, ) { const supabase = createCustomClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false, }, }, ); try { const providers = toArray(message?.Saadetis?.Teenused.Teostaja); const analysisGroups = providers.flatMap((provider) => toArray(provider.UuringuGrupp), ); if (!message || !analysisGroups.length) { return supabase.schema('audit').from('sync_entries').insert({ operation: 'ANALYSES_SYNC', comment: 'No data received', status: SyncStatus.Fail, changed_by_role: 'service_role', }); } for (const analysisGroup of analysisGroups) { await saveAnalysisGroup(analysisGroup, supabase); } await supabase.schema('audit').from('sync_entries').insert({ operation: 'ANALYSES_SYNC', status: SyncStatus.Success, changed_by_role: 'service_role', }); } catch (e) { console.error(e); await supabase .schema('audit') .from('sync_entries') .insert({ operation: 'ANALYSES_SYNC', status: SyncStatus.Fail, comment: JSON.stringify(e), changed_by_role: 'service_role', }); } } export async function composeOrderXML({ person, orderedAnalysisElementsIds, orderedAnalysesIds, orderId, orderCreatedAt, comment, }: { person: { idCode: string; firstName: string; lastName: string; phone: string; }; orderedAnalysisElementsIds: number[]; orderedAnalysesIds: number[]; orderId: string; orderCreatedAt: Date; comment?: string; }) { const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds }); if (analysisElements.length !== orderedAnalysisElementsIds.length) { throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`); } const analyses = await getAnalyses({ ids: orderedAnalysesIds }); if (analyses.length !== orderedAnalysesIds.length) { throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`); } const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] = uniqBy( ( analysisElements?.flatMap(({ analysis_groups }) => analysis_groups) ?? [] ).concat( analyses?.flatMap( ({ analysis_elements }) => analysis_elements.analysis_groups, ) ?? [], ), 'id', ); const specimenSection = []; const analysisSection = []; let order = 1; for (const currentGroup of analysisGroups) { let relatedAnalysisElement = analysisElements?.find( (element) => element.analysis_groups.id === currentGroup.id, ); const relatedAnalyses = analyses?.filter((analysis) => { return analysis.analysis_elements.analysis_groups.id === currentGroup.id; }); if (!relatedAnalysisElement) { relatedAnalysisElement = relatedAnalyses?.find( (relatedAnalysis) => relatedAnalysis.analysis_elements.analysis_groups.id === currentGroup.id, )?.analysis_elements; } if (!relatedAnalysisElement || !relatedAnalysisElement.material_groups) { throw new Error( `Failed to find related analysis element for group ${currentGroup.name} (id: ${currentGroup.id})`, ); } for (const group of relatedAnalysisElement?.material_groups as MaterjalideGrupp[]) { const materials = toArray(group.Materjal); const specimenXml = materials.flatMap( ({ MaterjaliNimi, MaterjaliTyyp, MaterjaliTyypOID, Konteiner }) => { return toArray(Konteiner).map((container) => getSpecimen( MaterjaliTyypOID, MaterjaliTyyp, MaterjaliNimi, order, container.ProovinouKoodOID, container.ProovinouKood, ), ); }, ); specimenSection.push(...specimenXml); } const groupXml = getAnalysisGroup( currentGroup.original_id, currentGroup.name, order, relatedAnalysisElement, ); order++; analysisSection.push(groupXml); } return ` ${getPais(USER, RECIPIENT, orderCreatedAt, orderId)} ${orderId} ${getClientInstitution()} ${getProviderInstitution()} ${getClientPerson(person)} ${comment ?? ''} ${getPatient(person)} ${getConfidentiality()} ${specimenSection.join('')} ${analysisSection?.join('')} `; } function getLatestMessage({ messages, excludedMessageIds, }: { messages?: Message[]; excludedMessageIds?: string[]; }) { if (!messages?.length) { return null; } const filtered = messages.filter(({ messageId }) => !excludedMessageIds?.includes(messageId)); if (!filtered.length) { return null; } return filtered.reduce((prev, current) => Number(prev.messageId) > Number(current.messageId) ? prev : current, { messageId: '' } as Message, ); } async function syncPrivateMessage({ messageResponse, order, }: { messageResponse: NonNullable; order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; }) { const supabase = getSupabaseServerAdminClient() const { data: analysisOrder, error: analysisOrderError } = await supabase .schema('medreport') .from('analysis_orders') .select('user_id') .eq('id', order.id); if (analysisOrderError || !analysisOrder?.[0]?.user_id) { throw new Error( `Could not find analysis order with id ${messageResponse.ValisTellimuseId}`, ); } const { data: analysisResponse, error } = await supabase .schema('medreport') .from('analysis_responses') .upsert( { analysis_order_id: order.id, order_number: messageResponse.TellimuseNumber, order_status: AnalysisOrderStatus[messageResponse.TellimuseOlek], user_id: analysisOrder[0].user_id, }, { onConflict: 'order_number', ignoreDuplicates: false }, ) .select('id'); if (error || !analysisResponse?.[0]?.id) { throw new Error( `Failed to insert or update analysis order response (external id: ${messageResponse?.TellimuseNumber})`, ); } const analysisGroups = toArray(messageResponse.UuringuGrupp); console.info(`Order has results for ${analysisGroups.length} analysis groups`); const responses: Omit< Tables<{ schema: 'medreport' }, 'analysis_response_elements'>, 'id' | 'created_at' | 'updated_at' >[] = []; const analysisResponseId = analysisResponse[0]!.id; for (const analysisGroup of analysisGroups) { const groupItems = toArray( analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'], ); console.info(`Order has results in group ${analysisGroup.UuringuGruppNimi} for ${groupItems.length} analysis elements`); for (const item of groupItems) { const element = item.UuringuElement; const elementAnalysisResponses = toArray(element.UuringuVastus); responses.push( ...elementAnalysisResponses.map((response) => ({ analysis_element_original_id: element.UuringId, analysis_response_id: analysisResponseId, norm_lower: response.NormAlum?.['#text'] ?? null, norm_lower_included: response.NormAlum?.['@_kaasaarvatud'].toLowerCase() === 'jah', norm_status: response.NormiStaatus, norm_upper: response.NormYlem?.['#text'] ?? null, norm_upper_included: response.NormYlem?.['@_kaasaarvatud'].toLowerCase() === 'jah', response_time: response.VastuseAeg ?? null, response_value: response.VastuseVaartus, unit: element.Mootyhik ?? null, original_response_element: element, analysis_name: element.UuringNimi || element.KNimetus, })), ); } } const { error: deleteError } = await supabase .schema('medreport') .from('analysis_response_elements') .delete() .eq('analysis_response_id', analysisResponseId); if (deleteError) { throw new Error( `Failed to clean up response elements for response id ${analysisResponseId}`, ); } const { error: elementInsertError } = await supabase .schema('medreport') .from('analysis_response_elements') .insert(responses); if (elementInsertError) { throw new Error( `Failed to insert order response elements for response id ${analysisResponseId}`, ); } const { data: allOrderResponseElements} = await supabase .schema('medreport') .from('analysis_response_elements') .select('*') .eq('analysis_response_id', analysisResponseId) .throwOnError(); const expectedOrderResponseElements = order.analysis_element_ids?.length ?? 0; if (allOrderResponseElements.length !== expectedOrderResponseElements) { return { isPartial: true }; } const statusFromResponse = AnalysisOrderStatus[messageResponse.TellimuseOlek]; return { isCompleted: statusFromResponse === 'COMPLETED' }; } export async function sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements, }: { medusaOrderId: string; orderedAnalysisElements: { analysisElementId: number }[]; }) { const medreportOrder = await getOrder({ medusaOrderId }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); const orderXml = await composeOrderXML({ person: { idCode: account.personal_code!, firstName: account.name ?? '', lastName: account.last_name ?? '', phone: account.phone ?? '', }, orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId), orderedAnalysesIds: [], orderId: medusaOrderId, orderCreatedAt: new Date(medreportOrder.created_at), comment: '', }); await sendPrivateMessage(orderXml); await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); } export async function getOrderedAnalysisElementsIds({ medusaOrder, }: { medusaOrder: StoreOrder; }): Promise<{ analysisElementId: number; }[]> { const countryCodes = await listRegions(); const countryCode = countryCodes[0]!.countries![0]!.iso_2!; async function getOrderedAnalysisElements(medusaOrder: StoreOrder) { const originalIds = (medusaOrder?.items ?? []) .map((a) => a.product?.metadata?.analysisIdOriginal) .filter((a) => typeof a === 'string') as string[]; const analysisElements = await getAnalysisElements({ originalIds }); return analysisElements.map(({ id }) => ({ analysisElementId: id })); } async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) { const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX)); const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[]; if (orderedPackageIds.length === 0) { return []; } console.info(`Order has ${orderedPackageIds.length} packages`); const { response: { products: orderedPackagesProducts } } = await listProducts({ countryCode, queryParams: { limit: 100, id: orderedPackageIds }, }); console.info(`Order has ${orderedPackagesProducts.length} packages = ${JSON.stringify(orderedPackageIds, null, 2)}`); if (orderedPackagesProducts.length !== orderedPackageIds.length) { throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`); } const ids = getAnalysisElementMedusaProductIds(orderedPackagesProducts); if (ids.length === 0) { return []; } const { response: { products: analysisPackagesProducts } } = await listProducts({ countryCode, queryParams: { limit: 100, id: ids }, }); if (analysisPackagesProducts.length !== ids.length) { throw new Error(`Got ${analysisPackagesProducts.length} analysis packages products, expected ${ids.length}`); } const originalIds = analysisPackagesProducts .map(({ metadata }) => metadata?.analysisIdOriginal) .filter((id) => typeof id === 'string'); if (originalIds.length !== ids.length) { throw new Error(`Got ${originalIds.length} analysis packages products with analysisIdOriginal, expected ${ids.length}`); } const analysisElements = await getAnalysisElements({ originalIds }); return analysisElements.map(({ id }) => ({ analysisElementId: id })); } const [analysisPackageElements, orderedAnalysisElements] = await Promise.all([ getOrderedAnalysisPackages(medusaOrder), getOrderedAnalysisElements(medusaOrder), ]); return [...analysisPackageElements, ...orderedAnalysisElements]; }