514 lines
18 KiB
TypeScript
514 lines
18 KiB
TypeScript
'use server';
|
|
|
|
import type { PostgrestError } from '@supabase/supabase-js';
|
|
import axios from 'axios';
|
|
|
|
import {
|
|
GetMessageListResponse,
|
|
MedipostAction,
|
|
} from '@/lib/types/medipost';
|
|
import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis';
|
|
import type {
|
|
ResponseUuringuGrupp,
|
|
MedipostOrderResponse,
|
|
UuringElement,
|
|
} from '@/packages/shared/src/types/medipost-analysis';
|
|
import { toArray } from '@kit/shared/utils';
|
|
import type { AnalysisOrder } from '~/lib/types/analysis-order';
|
|
import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element';
|
|
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
|
|
|
|
import { Tables } from '@kit/supabase/database';
|
|
import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
|
|
import { getAnalysisElementsAdmin } from '../analysis-element.service';
|
|
import { getAnalyses } from '../analyses.service';
|
|
import { upsertMedipostActionLog, getLatestMessage } from './medipostMessageBase.service';
|
|
import { validateMedipostResponse } from './medipostValidate.service';
|
|
import { getAnalysisOrder, updateAnalysisOrderStatus } from '../order.service';
|
|
import { parseXML } from '../util/xml.service';
|
|
import { composeOrderXML, OrderedAnalysisElement } from './medipostXML.service';
|
|
import { getAccountAdmin } from '../account.service';
|
|
import { logMedipostDispatch } from '../audit.service';
|
|
import { MedipostValidationError } from './MedipostValidationError';
|
|
import { upsertAnalysisResponseElement, getExistingAnalysisResponseElements, upsertAnalysisResponse } from '../analysis-order.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 = false as boolean;
|
|
|
|
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: {
|
|
UuringuElement: {
|
|
UuringOlek: status,
|
|
UuringId: analysisElementOriginalId,
|
|
},
|
|
},
|
|
responseValue,
|
|
log,
|
|
}: {
|
|
existingElements: Pick<AnalysisResponseElement, 'analysis_element_original_id' | 'status' | 'response_value'>[];
|
|
groupUuring: { UuringuElement: Pick<UuringElement, 'UuringOlek' | 'UuringId'> };
|
|
responseValue: number | null;
|
|
log: ReturnType<typeof logger>;
|
|
}) {
|
|
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<ResponseUuringuGrupp, 'UuringuGruppNimi' | 'Uuring'>;
|
|
existingElements: Pick<AnalysisResponseElement, 'analysis_element_original_id' | 'status' | 'response_value'>[];
|
|
log: ReturnType<typeof logger>;
|
|
}) {
|
|
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<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) {
|
|
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 (e) {
|
|
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 });
|
|
|
|
await upsertMedipostActionLog({
|
|
action: 'sync_analysis_results_from_medipost',
|
|
xml: privateMessageXml,
|
|
hasAnalysisResults: true,
|
|
medipostPrivateMessageId: privateMessageId,
|
|
medusaOrderId,
|
|
medipostExternalOrderId,
|
|
});
|
|
if (status.isPartial) {
|
|
await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' });
|
|
hasAnalysisResponse = true;
|
|
hasPartialAnalysisResponse = true;
|
|
} else if (status.isCompleted) {
|
|
await 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,
|
|
orderCreatedAt: new Date(medreportOrder.created_at),
|
|
comment: '',
|
|
});
|
|
|
|
try {
|
|
await sendPrivateMessage(orderXml);
|
|
} catch (e) {
|
|
const isMedipostError = e instanceof MedipostValidationError;
|
|
if (isMedipostError) {
|
|
await logMedipostDispatch({
|
|
medusaOrderId,
|
|
isSuccess: false,
|
|
isMedipostError,
|
|
errorMessage: e.response,
|
|
});
|
|
await upsertMedipostActionLog({
|
|
action: 'send_order_to_medipost',
|
|
xml: orderXml,
|
|
hasAnalysisResults: false,
|
|
medusaOrderId,
|
|
responseXml: e.response,
|
|
hasError: true,
|
|
});
|
|
} else {
|
|
await logMedipostDispatch({
|
|
medusaOrderId,
|
|
isSuccess: false,
|
|
isMedipostError,
|
|
});
|
|
await upsertMedipostActionLog({
|
|
action: 'send_order_to_medipost',
|
|
xml: orderXml,
|
|
hasAnalysisResults: false,
|
|
medusaOrderId,
|
|
hasError: true,
|
|
});
|
|
}
|
|
|
|
throw e;
|
|
}
|
|
await logMedipostDispatch({
|
|
medusaOrderId,
|
|
isSuccess: true,
|
|
isMedipostError: false,
|
|
});
|
|
await upsertMedipostActionLog({
|
|
action: 'send_order_to_medipost',
|
|
xml: orderXml,
|
|
hasAnalysisResults: false,
|
|
medusaOrderId,
|
|
});
|
|
await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' });
|
|
}
|