51 Commits

Author SHA1 Message Date
Karli
269b4c3e27 test 2025-11-11 23:18:45 +02:00
Karli
0878b5d1bd Merge branch 'main' into develop 2025-11-10 16:56:54 +02:00
Karli
6ee2e65938 MED-238: Improve logging 2025-11-10 16:54:01 +02:00
Karli
633b6db1af MED-238: Fix "client.auth.getUser" cannot be used in job context 2025-11-07 10:24:33 +02:00
Karli
452440e8db MED-238: Fix "client.auth.getUser" cannot be used in job context 2025-11-07 10:24:10 +02:00
641ee91c90 Merge pull request #147 from MR-medreport/hotfix/error-logs-1107
hotfix: fix errors from aws logs
2025-11-07 10:02:12 +02:00
Karli
2aa2ce9ce1 MED-238: Update query limit, default=5 2025-11-07 10:01:29 +02:00
Karli
48381b2c27 MED-240: Fix error "[PostgrestError]: "failed to parse filter (not.is.COMPLETED)" (line 1, column 8)" 2025-11-07 10:01:16 +02:00
Karli
2972988211 MED-240: Fix error "Error syncing analysis results Error [AuthSessionMissingError]: Auth session missing!" 2025-11-07 10:01:10 +02:00
ac80460d93 Merge pull request #146 from MR-medreport/feature/MED-238-240
MED-238,240: Fix errors from logs
2025-11-07 09:27:05 +02:00
Karli
58958c8ace MED-238: Update query limit, default=5 2025-11-06 23:28:26 +02:00
Karli
b76bdf7622 MED-240: Use retries for medusa query, "ECONNRESET" 2025-11-06 23:16:51 +02:00
Karli
c002eeb74b MED-240: Fix error "[PostgrestError]: "failed to parse filter (not.is.COMPLETED)" (line 1, column 8)" 2025-11-06 20:17:11 +02:00
Karli
5c171fb930 MED-240: Fix error "Error syncing analysis results Error [AuthSessionMissingError]: Auth session missing!" 2025-11-06 20:10:11 +02:00
danelkungla
99f20cce39 Merge pull request #145 from MR-medreport/develop
fix wrapping
2025-10-29 11:02:28 +02:00
danelkungla
f37d3e19fe Merge pull request #144 from MR-medreport/develop
main <- develop
2025-10-28 16:52:32 +02:00
danelkungla
a43624e559 Merge pull request #142 from MR-medreport/develop
fix analysis order page
2025-10-23 12:32:22 +03:00
danelkungla
3ad7afe2be main <- develop
main <- develop
2025-10-23 12:23:01 +03:00
danelkungla
4919c4fc12 Merge pull request #139 from MR-medreport/develop
main <- develop
2025-10-21 12:10:54 +03:00
Danel Kungla
b94e633742 Update CONNECTED_ONLINE url 2025-10-21 10:05:13 +03:00
danelkungla
766e44e5c3 Merge pull request #138 from MR-medreport/develop
main <- develop
2025-10-10 18:11:20 +03:00
danelkungla
0f962ef59a Merge pull request #136 from MR-medreport/develop
main <- develop
2025-10-09 19:01:09 +03:00
danelkungla
a5ddd790f6 MAIN <- develop
MAIN <- develop
2025-10-08 18:40:03 +03:00
danelkungla
c07b97fb3a main <- develop
main <- develop
2025-10-07 18:44:59 +03:00
danelkungla
0e14428518 main <- develop
main <- develop
2025-10-07 09:42:41 +03:00
danelkungla
839882f616 main <- develop
main <- develop
2025-10-06 19:45:45 +03:00
danelkungla
c66f71b01c main <- develop
main <- develop
2025-10-06 19:15:14 +03:00
danelkungla
6f01f31f22 Merge pull request #124 from MR-medreport/develop
minor fixes
2025-10-06 07:43:13 +03:00
danelkungla
975ee20254 Merge pull request #123 from MR-medreport/develop
fix mfa login after keycloak
2025-10-04 17:42:38 +03:00
danelkungla
6c9ab76439 Merge pull request #122 from MR-medreport/develop
main <- develop
2025-10-03 16:42:53 +03:00
danelkungla
eeea6b0d6f Merge pull request #121 from MR-medreport/develop
develop -> main
2025-10-03 12:44:25 +03:00
Danel Kungla
ca88387071 restart prod 2025-10-02 11:29:08 +03:00
Danel Kungla
45f9283e55 restart prod 2025-10-01 16:21:52 +03:00
danelkungla
bac2fba473 Merge pull request #118 from MR-medreport/develop
revert migration
2025-10-01 13:33:14 +03:00
Danel Kungla
96d3880229 Restart prod 2025-10-01 12:58:11 +03:00
danelkungla
8b41653bb5 Merge pull request #117 from MR-medreport/develop
develop -> main
2025-10-01 11:48:46 +03:00
Danel Kungla
c0c4f5e3db Restart prod 2025-09-29 11:39:10 +03:00
danelkungla
45b77f6291 Merge pull request #113 from MR-medreport/develop
include credentials
2025-09-29 11:12:10 +03:00
danelkungla
5d4dc97d19 develop -> main
develop -> main
2025-09-29 11:11:03 +03:00
Danel Kungla
cc8c4093ff updated openai Prompt for live 2025-09-25 16:02:09 +03:00
d37a99d7cd Merge pull request #107 from MR-medreport/develop
develop -> main
2025-09-24 17:02:45 +03:00
d941d82373 rerun pipeline for updated aws parameters 2025-09-24 15:27:59 +03:00
857044793d Merge pull request #103 from MR-medreport/develop
develop -> main
2025-09-22 15:56:20 +03:00
ccbaba291e Merge pull request #99 from MR-medreport/develop
develop -> main
2025-09-19 11:38:30 +03:00
danelkungla
03ec6eb670 Merge pull request #95 from MR-medreport/develop
add user consent url
2025-09-12 18:37:19 +03:00
32ce3807e8 Merge pull request #94 from MR-medreport/develop
develop -> main
2025-09-12 11:26:24 +00:00
90aa6f6fd6 Merge pull request #90 from MR-medreport/develop
develop -> main
2025-09-10 07:13:24 +00:00
be1ba7ef16 Merge pull request #88 from MR-medreport/develop
develop -> main
2025-09-10 04:31:11 +00:00
d3d937dbb2 Merge pull request #85 from MR-medreport/develop
develop -> main
2025-09-09 12:50:30 +00:00
7baa1d43a2 Merge pull request #81 from MR-medreport/develop
develop -> main; keycloak, fixes etc
2025-09-09 07:10:01 +00:00
7a479787c8 Merge pull request #77 from MR-medreport/develop
develop -> main
2025-09-06 19:59:39 +00:00
14 changed files with 559 additions and 390 deletions

View File

@@ -59,3 +59,37 @@ MONTONIO_API_URL=https://sandbox-stargate.montonio.com
# JOBS # JOBS
JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197 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,7 +1,7 @@
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
import { getSupabaseServerClient } from '@/packages/supabase/src/clients/server-client'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client';
import { readPrivateMessageResponse } from '~/lib/services/medipost/medipostPrivateMessage.service'; import MedipostResultsSyncService from '~/lib/services/medipost/medipostResultsSync.service';
type ProcessedMessage = { type ProcessedMessage = {
messageId: string; messageId: string;
@@ -19,22 +19,23 @@ type GroupedResults = {
export default async function syncAnalysisResults() { export default async function syncAnalysisResults() {
console.info('Syncing analysis results'); console.info('Syncing analysis results');
const supabase = getSupabaseServerClient(); const supabase = getSupabaseServerAdminClient();
const api = createUserAnalysesApi(supabase); const api = createUserAnalysesApi(supabase);
const syncService = new MedipostResultsSyncService();
const processedMessages: ProcessedMessage[] = []; const processedMessages: ProcessedMessage[] = [];
const excludedMessageIds: string[] = []; const excludedMessageIds: string[] = [];
while (true) { while (true) {
const result = await readPrivateMessageResponse({ excludedMessageIds }); const result = await syncService.readPrivateMessageResponse({ excludedMessageIds });
if (result.messageId) { if (result.messageId) {
processedMessages.push(result as ProcessedMessage); processedMessages.push(result as ProcessedMessage);
} }
await api.sendAnalysisResultsNotification({ // await api.sendAnalysisResultsNotification({
hasFullAnalysisResponse: result.hasFullAnalysisResponse, // hasFullAnalysisResponse: result.hasFullAnalysisResponse,
hasPartialAnalysisResponse: result.hasAnalysisResponse, // hasPartialAnalysisResponse: result.hasAnalysisResponse,
analysisOrderId: result.analysisOrderId, // analysisOrderId: result.analysisOrderId,
}); // });
if (!result.messageId) { if (!result.messageId) {
console.info('No more messages to process'); console.info('No more messages to process');

View File

@@ -7,6 +7,7 @@ import { ServiceCategory } from '../../_components/service-categories';
async function ttoServicesLoader() { async function ttoServicesLoader() {
const response = await getProductCategories({ const response = await getProductCategories({
fields: '*products, is_active, metadata', fields: '*products, is_active, metadata',
limit: 100,
}); });
const heroCategories = response.product_categories?.filter( const heroCategories = response.product_categories?.filter(

View File

@@ -7,7 +7,7 @@ async function getAssignedOrderIds() {
.schema('medreport') .schema('medreport')
.from('doctor_analysis_feedback') .from('doctor_analysis_feedback')
.select('analysis_order_id') .select('analysis_order_id')
.not('status', 'is', 'COMPLETED') .not('status', 'eq', 'COMPLETED')
.not('doctor_user_id', 'is', null) .not('doctor_user_id', 'is', null)
.throwOnError(); .throwOnError();

View File

@@ -1,12 +1,8 @@
'use server'; 'use server';
import type { PostgrestError } from '@supabase/supabase-js'; import { MedipostAction } from '@/lib/types/medipost';
import { GetMessageListResponse, MedipostAction } from '@/lib/types/medipost';
import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api'; import { createUserAnalysesApi } from '@/packages/features/user-analyses/src/server/api';
import { AnalysisOrderStatus } from '@/packages/shared/src/types/medipost-analysis';
import type { import type {
MedipostOrderResponse,
ResponseUuringuGrupp, ResponseUuringuGrupp,
UuringElement, UuringElement,
} from '@/packages/shared/src/types/medipost-analysis'; } from '@/packages/shared/src/types/medipost-analysis';
@@ -14,77 +10,27 @@ import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/se
import axios from 'axios'; import axios from 'axios';
import { toArray } from '@kit/shared/utils'; import { toArray } from '@kit/shared/utils';
import { Tables } from '@kit/supabase/database';
import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element'; import type { AnalysisResponseElement } from '~/lib/types/analysis-response-element';
import type { AnalysisOrder } from '~/lib/types/order';
import { getAccountAdmin } from '../account.service'; import { getAccountAdmin } from '../account.service';
import { getAnalyses } from '../analyses.service'; import { getAnalyses } from '../analyses.service';
import { getAnalysisElementsAdmin } from '../analysis-element.service'; import { getAnalysisElementsAdmin } from '../analysis-element.service';
import {
getExistingAnalysisResponseElements,
upsertAnalysisResponse,
upsertAnalysisResponseElement,
} from '../analysis-order.service';
import { logMedipostDispatch } from '../audit.service'; import { logMedipostDispatch } from '../audit.service';
import { getAnalysisOrder } from '../order.service'; import { getAnalysisOrder } from '../order.service';
import { parseXML } from '../util/xml.service';
import { MedipostValidationError } from './MedipostValidationError'; import { MedipostValidationError } from './MedipostValidationError';
import { import {
getLatestMessage,
upsertMedipostActionLog, upsertMedipostActionLog,
} from './medipostMessageBase.service'; } from './medipostMessageBase.service';
import { validateMedipostResponse } from './medipostValidate.service'; import { validateMedipostResponse } from './medipostValidate.service';
import { OrderedAnalysisElement, composeOrderXML } from './medipostXML.service'; import { OrderedAnalysisElement, composeOrderXML } from './medipostXML.service';
import type { Logger } from './types';
const BASE_URL = process.env.MEDIPOST_URL!; const BASE_URL = process.env.MEDIPOST_URL!;
const USER = process.env.MEDIPOST_USER!; const USER = process.env.MEDIPOST_USER!;
const PASSWORD = process.env.MEDIPOST_PASSWORD!; const PASSWORD = process.env.MEDIPOST_PASSWORD!;
const RECIPIENT = process.env.MEDIPOST_RECIPIENT!; 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<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({ export async function canCreateAnalysisResponseElement({
existingElements, existingElements,
groupUuring: { groupUuring: {
@@ -101,7 +47,7 @@ export async function canCreateAnalysisResponseElement({
UuringuElement: Pick<UuringElement, 'UuringOlek' | 'UuringId'>; UuringuElement: Pick<UuringElement, 'UuringOlek' | 'UuringId'>;
}; };
responseValue: number | null; responseValue: number | null;
log: ReturnType<typeof logger>; log: Logger;
}) { }) {
const existingAnalysisResponseElement = existingElements.find( const existingAnalysisResponseElement = existingElements.find(
({ analysis_element_original_id }) => ({ analysis_element_original_id }) =>
@@ -138,7 +84,7 @@ export async function getAnalysisResponseElementsForGroup({
AnalysisResponseElement, AnalysisResponseElement,
'analysis_element_original_id' | 'status' | 'response_value' 'analysis_element_original_id' | 'status' | 'response_value'
>[]; >[];
log: ReturnType<typeof logger>; log: Logger;
}) { }) {
const groupUuringItems = toArray( const groupUuringItems = toArray(
analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'], analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'],
@@ -211,284 +157,6 @@ export async function getAnalysisResponseElementsForGroup({
return results; 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) {
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',
});
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) { export async function sendPrivateMessage(messageXml: string) {
const body = new FormData(); const body = new FormData();
body.append('Action', MedipostAction.SendPrivateMessage); body.append('Action', MedipostAction.SendPrivateMessage);
@@ -508,27 +176,6 @@ export async function sendPrivateMessage(messageXml: string) {
await validateMedipostResponse(data); 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({ export async function sendOrderToMedipost({
medusaOrderId, medusaOrderId,
orderedAnalysisElements, orderedAnalysisElements,

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

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

View File

@@ -72,6 +72,7 @@ export async function getAnalysisOrder({
const { data: order, error } = await query.single(); const { data: order, error } = await query.single();
if (error) { if (error) {
console.error("Get analysis order error", error);
throw new Error( throw new Error(
`Failed to get order by medusaOrderId=${medusaOrderId} or analysisOrderId=${analysisOrderId}, message=${error.message}, data=${JSON.stringify(order)}`, `Failed to get order by medusaOrderId=${medusaOrderId} or analysisOrderId=${analysisOrderId}, message=${error.message}, data=${JSON.stringify(order)}`,
); );

View File

@@ -15,6 +15,12 @@ import {
removeCartId, removeCartId,
setAuthToken, setAuthToken,
} from './cookies'; } from './cookies';
import { withRetries } from '../util/with-retries';
const MedusaApiRetriesDefaultConfig = {
attempts: 3,
baseDelayMs: 1000,
};
export const retrieveCustomer = export const retrieveCustomer =
async (): Promise<HttpTypes.StoreCustomer | null> => { async (): Promise<HttpTypes.StoreCustomer | null> => {
@@ -268,11 +274,21 @@ export const updateCustomerAddress = async (
}; };
async function medusaLogin(email: string, password: string) { async function medusaLogin(email: string, password: string) {
const token = await sdk.auth.login('customer', 'emailpass', { const token = await withRetries(async () => {
email, const loginToken = await sdk.auth.login('customer', 'emailpass', {
password, email,
password,
});
if (typeof loginToken !== 'string') {
throw new Error('Failed to login Medusa account');
}
return loginToken;
}, {
...MedusaApiRetriesDefaultConfig,
label: 'medusa.auth.login',
}); });
await setAuthToken(token as string);
await setAuthToken(token);
try { try {
await transferCart(); await transferCart();
@@ -303,20 +319,38 @@ async function medusaRegister({
`Creating new Medusa account for Keycloak user with email=${email}`, `Creating new Medusa account for Keycloak user with email=${email}`,
); );
const registerToken = await sdk.auth.register('customer', 'emailpass', { const registerToken = await withRetries(
email, async () => {
password, const token = await sdk.auth.register('customer', 'emailpass', {
}); email,
password,
});
if (typeof token !== 'string') {
throw new Error('Failed to register Medusa account');
}
return token;
},
{
...MedusaApiRetriesDefaultConfig,
label: 'medusa.auth.register',
},
);
await setAuthToken(registerToken); await setAuthToken(registerToken);
console.info( console.info(
`Creating new Medusa customer profile for Keycloak user with email=${email} and name=${name} and lastName=${lastName}`, `Creating new Medusa customer profile for Keycloak user with email=${email} and name=${name} and lastName=${lastName}`,
); );
await sdk.store.customer.create( await withRetries(
{ email, first_name: name, last_name: lastName }, async () => {
{}, await sdk.store.customer.create(
{ email, first_name: name, last_name: lastName },
{},
{ ...(await getAuthHeaders()) },
);
},
{ {
...(await getAuthHeaders()), ...MedusaApiRetriesDefaultConfig,
label: 'medusa.customer.create',
}, },
); );
} }

View File

@@ -8,3 +8,4 @@ export * from './money';
export * from './product'; export * from './product';
export * from './repeat'; export * from './repeat';
export * from './sort-products'; export * from './sort-products';
export * from './with-retries';

View File

@@ -0,0 +1,45 @@
type RetryOptions = {
attempts?: number;
baseDelayMs?: number;
label?: string;
};
function sleep(delay: number) {
return new Promise((resolve) => setTimeout(resolve, delay));
}
export async function withRetries<T>(
operation: () => Promise<T>,
{ attempts = 3, baseDelayMs = 500, label }: RetryOptions = {}
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt === attempts) {
break;
}
const delay = baseDelayMs * 2 ** (attempt - 1);
if (label) {
console.warn(
`Retrying ${label}, attempt ${attempt + 1}/${attempts} in ${delay}ms`,
error
);
}
if (delay > 0) {
await sleep(delay);
}
}
}
throw lastError instanceof Error
? lastError
: new Error("Operation failed after retries");
}

View File

@@ -507,11 +507,24 @@ class UserAnalysesApi {
if (!analysisOrderId) { if (!analysisOrderId) {
return; return;
} }
const { data, error: userError } = await this.client.auth.getUser(); const analysisOrder = await this.getAnalysisOrder({ analysisOrderId });
if (userError) { const userId = analysisOrder.user_id;
throw userError; const { data: account } = await this.client
.schema('medreport')
.from('accounts')
.select('id')
.eq('primary_owner_user_id', userId)
.maybeSingle()
.throwOnError();
const accountId = account?.id;
if (!accountId) {
console.warn(
`Order ${analysisOrderId} got new responses but no account found for user_id=${userId}. Skipping notification. AnalysisOrder=${JSON.stringify(analysisOrder)}`,
);
return;
} }
const { user } = data;
const notificationsApi = createNotificationsApi(this.client); const notificationsApi = createNotificationsApi(this.client);
const { t } = await createI18nServerInstance(); const { t } = await createI18nServerInstance();
@@ -521,7 +534,7 @@ class UserAnalysesApi {
if (hasFullAnalysisResponse || hasPartialAnalysisResponse) { if (hasFullAnalysisResponse || hasPartialAnalysisResponse) {
await notificationsApi.createNotification({ await notificationsApi.createNotification({
account_id: user.id, account_id: accountId,
body: t('analysis-results:notification.body'), body: t('analysis-results:notification.body'),
link: `${pathsConfig.app.analysisResults}/${analysisOrderId}`, link: `${pathsConfig.app.analysisResults}/${analysisOrderId}`,
}); });

View File

@@ -26,8 +26,8 @@ export function getServiceRoleKey() {
*/ */
export function warnServiceRoleKeyUsage() { export function warnServiceRoleKeyUsage() {
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
console.warn( // 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.`, // `[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.`,
); // );
} }
} }

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

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
MEDUSA_ORDER_ID="order_01K1TQQHZGPXKDHAH81TDSNGXR" MEDUSA_ORDER_ID="order_01K9SMB00HJ1W37S1HM0DN2SFV"
# HOSTNAME="https://test.medreport.ee" # HOSTNAME="https://test.medreport.ee"
# JOBS_API_TOKEN="fd26ec26-70ed-11f0-9e95-431ac3b15a84" # JOBS_API_TOKEN="fd26ec26-70ed-11f0-9e95-431ac3b15a84"
@@ -33,7 +33,7 @@ function sync_analysis_groups_store() {
# Requirements # Requirements
# 1. Sync analysis groups from Medipost to B2B # 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) # 2. Optional - sync all Medipost analysis groups from B2B to Medusa (or add manually)
#sync_analysis_groups_store #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 # 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 # 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 # 5. Run `sync_analysis_results` to sync the all new Medipost results to B2B
# sync_analysis_results # sync_analysis_results