1 Commits

Author SHA1 Message Date
Karli
269b4c3e27 test 2025-11-11 23:18:45 +02:00
24 changed files with 472 additions and 724 deletions

View File

@@ -59,3 +59,37 @@ MONTONIO_API_URL=https://sandbox-stargate.montonio.com
# JOBS
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197
MEDUSA_BACKEND_URL=http://5.181.51.38:9000
MEDUSA_BACKEND_PUBLIC_URL=http://5.181.51.38:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_0ec86252438b38ce18d5601f7877e4395d7e0a6afa8687dfea8d37af33015633
NEXT_PUBLIC_SUPABASE_URL=http://5.181.51.38:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
NEXT_PUBLIC_SUPABASE_URL=https://klocrucggryikaxzvxgc.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTY5ODQ2MjgsImV4cCI6MjA3MjU2MDYyOH0.2XOQngowcymiSUZO_XEEWAWzco2uRIjwG7TAeRRLIdU
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imtsb2NydWNnZ3J5aWtheHp2eGdjIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1Njk4NDYyOCwiZXhwIjoyMDcyNTYwNjI4fQ.1UZR7AqSD9bOy1gtZRGhOCNoESsw2W-DoFDDsNNMwoE
MEDUSA_BACKEND_URL=https://backoffice-test.medreport.ee
MEDUSA_BACKEND_PUBLIC_URL=https://backoffice-test.medreport.ee
MEDUSA_SECRET_API_KEY=sk_5ac1c1c12c144cd744b6c881050d459e339ddf6a3d14eda271a0cc4f9d3812cb
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_e740b9ca22b31c4b44862044f001dbcf8f46d47d40f430733d0c75bef14d2d6a
#MEDUSA_BACKEND_URL=https://backoffice.medreport.ee
#MEDUSA_BACKEND_PUBLIC_URL=https://backoffice.medreport.ee
#MEDUSA_SECRET_API_KEY=sk_fdb1808fbabf62979cc46316aa997378ffbb87882883e8f5c3ee47cee39dcac5
#NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_068d930c33fea53608a410d84a51935f6ce2ccec5bef8e0ecf75eaee602ac486
# PROD
NEXT_PUBLIC_SUPABASE_URL=https://oqsdacktkhmbylmzstjq.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDY1MjgxMjMsImV4cCI6MjA2MjEwNDEyM30.LdHCTWxijFmhXdnT9KVuLRAVbtSwY7OO-oLtpd8GmO0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9xc2RhY2t0a2htYnlsbXpzdGpxIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NjUyODEyMywiZXhwIjoyMDYyMTA0MTIzfQ.KVcnkZ21Pd0XkJho23dZqFHawVTLQqfvF7l2RxsELLk
MEDIPOST_URL=https://medipost2.medisoft.ee:8443/Medipost/MedipostServlet
MEDIPOST_USER=medreport
MEDIPOST_PASSWORD=85MXFFDB7
MEDIPOST_RECIPIENT=HTI
MEDIPOST_MESSAGE_SENDER=medreport
MEDIPOST_ENABLE_DISPATCH_ON_MONTONIO_CALLBACK=false

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react';
@@ -21,22 +20,6 @@ const ErrorPage = ({
}) => {
useCaptureException(error);
// Ignore next.js internal transient navigation errors that occur during auth state changes
const isTransientNavigationError =
error?.message?.includes('Error in input stream') ||
error?.message?.includes('AbortError') ||
(error?.name === 'ChunkLoadError');
useEffect(() => {
if (isTransientNavigationError && typeof window !== 'undefined') {
window.location.href = '/';
}
}, [isTransientNavigationError]);
if (isTransientNavigationError) {
return <div />;
}
return (
<div className={'flex h-screen flex-1 flex-col'}>
<SiteHeader />

View File

@@ -1,6 +1,5 @@
'use client';
import { useEffect } from 'react';
import Link from 'next/link';
import { ArrowLeft, MessageCircle } from 'lucide-react';
@@ -22,22 +21,6 @@ const GlobalErrorPage = ({
}) => {
useCaptureException(error);
// Ignore next.js internal transient navigation errors that occur during auth state changes
const isTransientNavigationError =
error?.message?.includes('Error in input stream') ||
error?.message?.includes('AbortError') ||
(error?.name === 'ChunkLoadError');
useEffect(() => {
if (isTransientNavigationError && typeof window !== 'undefined') {
window.location.href = '/';
}
}, [isTransientNavigationError]);
if (isTransientNavigationError) {
return <div />;
}
return (
<html>
<body>

View File

@@ -11,7 +11,6 @@ import { pathsConfig } from '@kit/shared/config';
import { Button } from '@kit/ui/button';
import { PageBody, PageHeader } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans';
import { formatDateAndTime } from '@kit/shared/utils';
import { loadCurrentUserAccount } from '~/home/(user)/_lib/server/load-user-account';
import { loadUserAnalysis } from '~/home/(user)/_lib/server/load-user-analysis';
@@ -104,14 +103,7 @@ export default async function AnalysisResultsPage({
<h6>
<Trans i18nKey={`orders:status.${analysisResponse.order.status}`} />
<ButtonTooltip
content={
<Trans
i18nKey="analysis-results:orderCreatedAt"
values={{
createdAt: formatDateAndTime(analysisResponse.order.createdAt)
}}
/>
}
content={`${analysisResponse.order.createdAt ? new Date(analysisResponse?.order?.createdAt).toLocaleString() : ''}`}
className="ml-6"
/>
</h6>

View File

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

View File

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

View File

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

View File

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

View File

@@ -244,7 +244,6 @@ export async function sendOrderToMedipost({
medusaOrderId,
responseXml: e.response,
hasError: true,
medipostPrivateMessageId: `send-order-to-medipost-${Date.now()}`,
});
} else {
console.error(
@@ -261,7 +260,6 @@ export async function sendOrderToMedipost({
hasAnalysisResults: false,
medusaOrderId,
hasError: true,
medipostPrivateMessageId: `send-order-to-medipost-${Date.now()}`,
});
}
@@ -280,7 +278,6 @@ export async function sendOrderToMedipost({
xml: orderXml,
hasAnalysisResults: false,
medusaOrderId,
medipostPrivateMessageId: `send-order-to-medipost-${Date.now()}`,
});
await createUserAnalysesApi(
getSupabaseServerAdminClient(),

View File

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

View File

@@ -0,0 +1,389 @@
import axios from 'axios';
import type { PostgrestError, SupabaseClient } from "@supabase/supabase-js";
import type { Database, Tables } from '@kit/supabase/database';
import { toArray } from '@kit/shared/utils';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { GetMessageListResponse } from '~/lib/types/medipost';
import { MedipostAction } from '~/lib/types/medipost';
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 {
MedipostOrderResponse,
ResponseUuringuGrupp,
} from '@/packages/shared/src/types/medipost-analysis';
import { createUserAnalysesApi } from "@/packages/features/user-analyses/src/server/api";
import { getAnalysisResponseElementsForGroup } from "./medipostPrivateMessage.service";
import { getAnalysisOrder } from "../order.service";
import { getLatestMessage, upsertMedipostActionLog } from "./medipostMessageBase.service";
import { getAccountAdmin } from "../account.service";
import { getExistingAnalysisResponseElements, upsertAnalysisResponse, upsertAnalysisResponseElement } from "../analysis-order.service";
import { validateMedipostResponse } from './medipostValidate.service';
import { parseXML } from '../util/xml.service';
import type { Logger } from './types';
interface ISyncResult {
messageId: string | null;
hasAnalysisResponse: boolean;
hasPartialAnalysisResponse: boolean;
hasFullAnalysisResponse: boolean;
medusaOrderId: string | undefined;
analysisOrderId: number | undefined;
}
const ERROR_RESPONSE: ISyncResult = {
messageId: null,
hasAnalysisResponse: false,
hasPartialAnalysisResponse: false,
hasFullAnalysisResponse: false,
medusaOrderId: undefined,
analysisOrderId: undefined,
};
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 = false;
// process.env.MEDIPOST_ENABLE_DELETE_RESPONSE_PRIVATE_MESSAGE_ON_READ ===
// 'true';
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 default class MedipostResultsSyncService {
private readonly client: SupabaseClient<Database>;
private readonly userAnalysesApi: ReturnType<typeof createUserAnalysesApi>;
constructor() {
this.client = getSupabaseServerAdminClient();
this.userAnalysesApi = createUserAnalysesApi(this.client);
}
public async readPrivateMessageResponse({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}): Promise<ISyncResult> {
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 this.getLatestPrivateMessageListItem({
excludedMessageIds,
});
messageId = privateMessage?.messageId ?? null;
if (!privateMessage || !messageId) {
return ERROR_RESPONSE;
}
const { messageId: privateMessageId } = privateMessage;
const { message: privateMessageContent, xml: privateMessageXml } =
await this.getPrivateMessage(privateMessageId);
const messageResponse = privateMessageContent?.Saadetis?.Vastus;
if (!messageResponse) {
console.info(`Skipping private message id=${privateMessageId} because it has no response`);
return ERROR_RESPONSE;
}
const medipostExternalOrderId =
privateMessageContent?.Saadetis?.Tellimus?.ValisTellimuseId ||
messageResponse?.ValisTellimuseId;
console.info("PATSIENT", JSON.stringify(messageResponse?.Patsient, null, 2));
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 {
...ERROR_RESPONSE,
messageId,
...(!hasInvalidOrderId && { medusaOrderId, analysisOrderId }),
};
}
let analysisOrder: AnalysisOrder;
try {
analysisOrder = await getAnalysisOrder({ analysisOrderId });
medusaOrderId = analysisOrder.medusa_order_id;
} catch (e) {
console.error("Get analysis order error", e);
await this.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 this.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 this.userAnalysesApi.updateAnalysisOrderStatus({
medusaOrderId,
orderStatus: 'PARTIAL_ANALYSIS_RESPONSE',
});
hasAnalysisResponse = true;
hasPartialAnalysisResponse = true;
} else if (status.isCompleted) {
await this.userAnalysesApi.updateAnalysisOrderStatus({
medusaOrderId,
orderStatus: 'FULL_ANALYSIS_RESPONSE',
});
await this.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,
};
}
private async 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 orderStatus = AnalysisOrderStatus[TellimuseOlek];
const log = logger(order, externalId, orderNumber);
const { data: analysisOrder } = await this.client
.schema('medreport')
.from('analysis_orders')
.select('id, user_id')
.eq('id', order.id)
.single()
.throwOnError();
console.info("ANALYSIS ORDER", JSON.stringify(analysisOrder, null, 2));
throw new Error("early return");
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 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}' (order id: ${order.id})`,
e as PostgrestError,
);
}
}
return (await this.hasAllAnalysisResponseElements({ analysisResponseId, order }))
? { isCompleted: orderStatus === 'COMPLETED' }
: { isPartial: true };
}
private async getLatestPrivateMessageListItem({
excludedMessageIds,
}: {
excludedMessageIds: string[];
}) {
const { data } = await axios.get<GetMessageListResponse>(BASE_URL, {
params: {
Action: MedipostAction.GetPrivateMessageList,
User: USER,
Password: PASSWORD,
},
});
if (data.code && data.code !== 0) {
throw new Error('Failed to get private message list');
}
return await getLatestMessage({
messages: data?.messages,
excludedMessageIds,
});
}
private async getNewAnalysisResponseElements({
analysisGroups,
existingElements,
log,
}: {
analysisGroups: ResponseUuringuGrupp[];
existingElements: AnalysisResponseElement[];
log: 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;
}
private async 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;
}
private async deletePrivateMessage(messageId: string) {
if (!IS_ENABLED_DELETE_PRIVATE_MESSAGE) {
console.info(`Skipping delete private message id=${messageId} because deleting is not enabled`);
return;
}
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})`);
}
}
private 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,
};
}
}

View File

@@ -1,11 +1,9 @@
'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,
@@ -26,20 +24,3 @@ export async function validateMedipostResponse(
throw new MedipostValidationError(response);
}
}
export async function validateOrderPerson({
analysisOrder,
patientPersonalCode,
}: {
analysisOrder: AnalysisOrder;
patientPersonalCode: string;
}) {
const orderPerson = await getAccountAdmin({
primaryOwnerUserId: analysisOrder.user_id,
});
if (orderPerson.personal_code !== patientPersonalCode) {
throw new Error(
`Order person personal code does not match Medipost message Patsient.Isikukood=${patientPersonalCode}, orderPerson.personal_code=${orderPerson.personal_code}`,
);
}
}

View File

@@ -1 +1,3 @@
export type Logger = (message: string, level?: 'info' | 'error' | 'warn', error?: Error | null) => void;
import type { PostgrestError } from '@supabase/supabase-js';
export type Logger = (message: string, error?: PostgrestError | null) => void;

View File

@@ -1,5 +1,6 @@
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';
@@ -82,7 +83,7 @@ export async function getAnalysisOrder({
export async function getAnalysisOrders({
orderStatus,
}: {
orderStatus?: AnalysisOrder['status'];
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
} = {}) {
const client = getSupabaseServerClient();
@@ -111,7 +112,7 @@ export async function getAnalysisOrdersAdmin({
orderStatus,
medusaOrderId,
}: {
orderStatus?: AnalysisOrder['status'];
orderStatus?: Tables<{ schema: 'medreport' }, 'analysis_orders'>['status'];
medusaOrderId?: string | null;
} = {}) {
const query = getSupabaseServerAdminClient()

View File

@@ -12,7 +12,7 @@ export function ButtonTooltip({
content,
className,
}: {
content?: string | React.ReactNode;
content?: string;
className?: string;
}) {
if (!content) return null;

View File

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

View File

@@ -26,8 +26,8 @@ export function getServiceRoleKey() {
*/
export function warnServiceRoleKeyUsage() {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`[Dev Only] This is a simple warning to let you know you are using the Supabase Service Role. Make sure it's the right call.`,
);
// console.warn(
// `[Dev Only] This is a simple warning to let you know you are using the Supabase Service Role. Make sure it's the right call.`,
// );
}
}

View File

@@ -65,9 +65,7 @@ export function useAuthChangeListener({
return;
}
// Redirect to home instead of reloading to avoid state mismatch errors
// during the transition
window.location.assign('/');
window.location.reload();
}
});

View File

@@ -24,6 +24,5 @@
"view": "View results",
"notification": {
"body": "You have new analysis results"
},
"orderCreatedAt": "Order created at: {{createdAt}}"
}
}

View File

@@ -24,6 +24,5 @@
"view": "Vaata tulemusi",
"notification": {
"body": "Teil on valmis uued analüüsi tulemused"
},
"orderCreatedAt": "Tellimus loodud: {{createdAt}}"
}
}

View File

@@ -23,6 +23,5 @@
"orderTitle": "Заказ {{orderNumber}}",
"notification": {
"body": "Teil on valmis uued analüüsi tulemused"
},
"orderCreatedAt": "Заказ создан: {{createdAt}}"
}
}

6
run-test-sync-local.sh Normal file → Executable file
View File

@@ -1,6 +1,6 @@
#!/bin/bash
MEDUSA_ORDER_ID="order_01K1TQQHZGPXKDHAH81TDSNGXR"
MEDUSA_ORDER_ID="order_01K9SMB00HJ1W37S1HM0DN2SFV"
# HOSTNAME="https://test.medreport.ee"
# JOBS_API_TOKEN="fd26ec26-70ed-11f0-9e95-431ac3b15a84"
@@ -33,7 +33,7 @@ function sync_analysis_groups_store() {
# Requirements
# 1. Sync analysis groups from Medipost to B2B
sync_analysis_groups
#sync_analysis_groups
# 2. Optional - sync all Medipost analysis groups from B2B to Medusa (or add manually)
#sync_analysis_groups_store
@@ -41,7 +41,7 @@ sync_analysis_groups
# 3. Set up products configurations in Medusa so B2B "Telli analüüs" page shows the product and you can do payment flow
# 4. After payment is done, run `send_medipost_test_response` to send the fake test results to Medipost
# send_medipost_test_response
send_medipost_test_response
# 5. Run `sync_analysis_results` to sync the all new Medipost results to B2B
# sync_analysis_results