From 43493c261c8d4b1d5c8abb1ff1ecfefa3e6d2989 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:51:11 +0300 Subject: [PATCH 01/30] feat(MED-131): move jobs to /api/job/* secured with key --- .env | 3 + .env.example | 2 + app/api/job/handler/load-env.ts | 8 ++ .../api/job/handler}/sync-analysis-groups.ts | 47 ++++----- .../api/job/handler}/sync-connected-online.ts | 18 +--- app/api/job/handler/types.ts | 39 ++++++++ app/api/job/handler/validate-api-key.ts | 9 ++ app/api/job/sync-analysis-groups/route.ts | 27 ++++++ app/api/job/sync-connected-online/route.ts | 27 ++++++ lib/services/medipost.service.ts | 17 ++-- lib/templates/medipost-order.ts | 96 +++++++++++-------- package.json | 4 +- 12 files changed, 204 insertions(+), 93 deletions(-) create mode 100644 app/api/job/handler/load-env.ts rename {jobs => app/api/job/handler}/sync-analysis-groups.ts (88%) rename {jobs => app/api/job/handler}/sync-connected-online.ts (91%) create mode 100644 app/api/job/handler/types.ts create mode 100644 app/api/job/handler/validate-api-key.ts create mode 100644 app/api/job/sync-analysis-groups/route.ts create mode 100644 app/api/job/sync-connected-online/route.ts diff --git a/.env b/.env index 04c866d..14a35b4 100644 --- a/.env +++ b/.env @@ -73,3 +73,6 @@ MONTONIO_API_URL=https://sandbox-stargate.montonio.com # MEDUSA MEDUSA_BACKEND_URL=http://localhost:9000 MEDUSA_BACKEND_PUBLIC_URL=http://localhost:9000 + +# JOBS +JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197 diff --git a/.env.example b/.env.example index ce9f94c..091949f 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,5 @@ NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY= NEXT_PUBLIC_MONTONIO_ACCESS_KEY=7da5d7fa-3383-4997-9435-46aa818f4ead MONTONIO_SECRET_KEY=rNZkzwxOiH93mzkdV53AvhSsbGidrgO2Kl5lE/IT7cvo MONTONIO_API_URL=https://sandbox-stargate.montonio.com + +JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197 diff --git a/app/api/job/handler/load-env.ts b/app/api/job/handler/load-env.ts new file mode 100644 index 0000000..9a218b6 --- /dev/null +++ b/app/api/job/handler/load-env.ts @@ -0,0 +1,8 @@ +import { config } from 'dotenv'; + +export default function loadEnv() { + config({ path: `.env` }); + if (['local', 'test', 'development', 'production'].includes(process.env.NODE_ENV!)) { + config({ path: `.env.${process.env.NODE_ENV}` }); + } +} diff --git a/jobs/sync-analysis-groups.ts b/app/api/job/handler/sync-analysis-groups.ts similarity index 88% rename from jobs/sync-analysis-groups.ts rename to app/api/job/handler/sync-analysis-groups.ts index d7e32d1..c0d56d8 100644 --- a/jobs/sync-analysis-groups.ts +++ b/app/api/job/handler/sync-analysis-groups.ts @@ -2,10 +2,10 @@ import { createClient as createCustomClient } from '@supabase/supabase-js'; import axios from 'axios'; import { format } from 'date-fns'; -import { config } from 'dotenv'; import { XMLParser } from 'fast-xml-parser'; +import { IMedipostResponse_GetPublicMessageList } from './types'; -function getLatestMessage(messages) { +function getLatestMessage(messages: IMedipostResponse_GetPublicMessageList['messages']): IMedipostResponse_GetPublicMessageList['messages'][number] | null { if (!messages?.length) { return null; } @@ -15,16 +15,12 @@ function getLatestMessage(messages) { ); } -export function toArray(input?: T | T[] | null): T[] { +function toArray(input?: T | T[] | null): T[] { if (!input) return []; return Array.isArray(input) ? input : [input]; } -async function syncData() { - if (process.env.NODE_ENV === 'local') { - config({ path: `.env.${process.env.NODE_ENV}` }); - } - +export default async function syncAnalysisGroups() { const baseUrl = process.env.MEDIPOST_URL; const user = process.env.MEDIPOST_USER; const password = process.env.MEDIPOST_PASSWORD; @@ -52,7 +48,7 @@ async function syncData() { }); try { - // GET LATEST PUBLIC MESSAGE ID + console.info('Getting latest public message id'); const { data: lastChecked } = await supabase .schema('audit') .from('sync_entries') @@ -60,34 +56,30 @@ async function syncData() { .eq('status', 'SUCCESS') .order('created_at') .limit(1); + const lastEntry = lastChecked?.[0]; + const lastCheckedDate = lastEntry + ? format(lastEntry.created_at, 'yyyy-MM-dd HH:mm:ss') + : null; - const lastCheckedDate = lastChecked?.length - ? { - LastChecked: format(lastChecked[0].created_at, 'yyyy-MM-dd HH:mm:ss'), - } - : {}; - - const { data, status } = await axios.get(baseUrl, { + console.info('Getting public message list'); + const { data, status } = await axios.get(baseUrl, { params: { Action: 'GetPublicMessageList', User: user, Password: password, - Sender: sender, - ...lastCheckedDate, + //Sender: sender, + // ...(lastCheckedDate && { LastChecked: lastCheckedDate }), MessageType: 'Teenus', }, }); - if (!data || status !== 200) { + if (!data || status !== 200 || data.code !== 0) { console.error("Failed to get public message list, status: ", status, data); throw new Error('Failed to get public message list'); } - if (data.code && data.code !== 0) { - throw new Error('Failed to get public message list'); - } - if (!data.messages?.length) { + console.info('No new data received'); return supabase.schema('audit').from('sync_entries').insert({ operation: 'ANALYSES_SYNC', comment: 'No new data received', @@ -96,9 +88,9 @@ async function syncData() { }); } - const latestMessage = getLatestMessage(data?.messages); - // GET PUBLIC MESSAGE WITH GIVEN ID + const latestMessage = getLatestMessage(data?.messages)!; + console.info('Getting public message with id: ', latestMessage.messageId); const { data: publicMessageData } = await axios.get(baseUrl, { params: { @@ -266,8 +258,10 @@ async function syncData() { } } + console.info('Inserting codes'); await supabase.schema('medreport').from('codes').upsert(codes); + console.info('Inserting sync entry'); await supabase.schema('audit').from('sync_entries').insert({ operation: 'ANALYSES_SYNC', status: 'SUCCESS', @@ -283,10 +277,9 @@ async function syncData() { comment: JSON.stringify(e), changed_by_role: 'service_role', }); + console.error(e); throw new Error( `Failed to sync public message data, error: ${JSON.stringify(e)}`, ); } } - -syncData(); diff --git a/jobs/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts similarity index 91% rename from jobs/sync-connected-online.ts rename to app/api/job/handler/sync-connected-online.ts index 4944bb7..2d0d9fb 100644 --- a/jobs/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -1,13 +1,10 @@ import { createClient as createCustomClient } from '@supabase/supabase-js'; import axios from 'axios'; -import { config } from 'dotenv'; -async function syncData() { - if (process.env.NODE_ENV === 'local') { - config({ path: `.env.${process.env.NODE_ENV}` }); - } +import type { IConnectedOnlineResponse_Search_Load } from './types'; +export default async function syncConnectedOnline() { const isProd = process.env.NODE_ENV === 'production'; const baseUrl = process.env.CONNECTED_ONLINE_URL; @@ -27,19 +24,14 @@ async function syncData() { }); try { - const response = await axios.post(`${baseUrl}/Search_Load`, { + const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, { headers: { 'Content-Type': 'application/json; charset=utf-8', }, param: "{'Value':'|et|-1'}", // get all available services in Estonian }); - const responseData: { - Value: any; - Data: any; - ErrorCode: number; - ErrorMessage: string; - } = JSON.parse(response.data.d); + const responseData: IConnectedOnlineResponse_Search_Load = JSON.parse(response.data.d); if (responseData?.ErrorCode !== 0) { throw new Error('Failed to get Connected Online data'); @@ -147,5 +139,3 @@ async function syncData() { ); } } - -syncData(); diff --git a/app/api/job/handler/types.ts b/app/api/job/handler/types.ts new file mode 100644 index 0000000..0f32e9f --- /dev/null +++ b/app/api/job/handler/types.ts @@ -0,0 +1,39 @@ +export interface IMedipostResponse_GetPublicMessageList { + code: number; + messages: { + messageId: string; + }[]; +} + +export interface IConnectedOnlineResponse_Search_Load { + Value: string; + Data: { + T_Lic: { + ID: number; + Name: string; + OnlineCanSelectWorker: boolean; + Email: string | null; + PersonalCodeRequired: boolean; + Phone: string | null; + }[]; + T_Service: { + ID: number; + ClinicID: number; + Code: string; + Description: string | null; + Display: string; + Duration: number; + HasFreeCodes: boolean; + Name: string; + NetoDuration: number; + OnlineHideDuration: number; + OnlineHidePrice: number; + Price: number; + PricePeriods: string | null; + RequiresPayment: boolean; + SyncID: string; + }[]; + }; + ErrorCode: number; + ErrorMessage: string; +} diff --git a/app/api/job/handler/validate-api-key.ts b/app/api/job/handler/validate-api-key.ts new file mode 100644 index 0000000..70af4ba --- /dev/null +++ b/app/api/job/handler/validate-api-key.ts @@ -0,0 +1,9 @@ +import { NextRequest } from "next/server"; + +export default function validateApiKey(request: NextRequest) { + const envApiKey = process.env.JOBS_API_TOKEN; + const requestApiKey = request.headers.get('x-jobs-api-key'); + if (requestApiKey !== envApiKey) { + throw new Error('Unauthorized'); + } +} diff --git a/app/api/job/sync-analysis-groups/route.ts b/app/api/job/sync-analysis-groups/route.ts new file mode 100644 index 0000000..c4c193d --- /dev/null +++ b/app/api/job/sync-analysis-groups/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import syncAnalysisGroups from "../handler/sync-analysis-groups"; +import loadEnv from "../handler/load-env"; +import validateApiKey from "../handler/validate-api-key"; + +export const GET = async (request: NextRequest) => { + loadEnv(); + + try { + validateApiKey(request); + } catch (e) { + return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); + } + + try { + await syncAnalysisGroups(); + console.info("Successfully synced analysis groups"); + return NextResponse.json({ + message: 'Successfully synced analysis groups', + }, { status: 200 }); + } catch (e) { + console.error("Error syncing analysis groups", e); + return NextResponse.json({ + message: 'Failed to sync analysis groups', + }, { status: 500 }); + } +}; diff --git a/app/api/job/sync-connected-online/route.ts b/app/api/job/sync-connected-online/route.ts new file mode 100644 index 0000000..03a16ee --- /dev/null +++ b/app/api/job/sync-connected-online/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import loadEnv from "../handler/load-env"; +import validateApiKey from "../handler/validate-api-key"; +import syncConnectedOnline from "../handler/sync-connected-online"; + +export const GET = async (request: NextRequest) => { + loadEnv(); + + try { + validateApiKey(request); + } catch (e) { + return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); + } + + try { + await syncConnectedOnline(); + console.info("Successfully synced connected-online"); + return NextResponse.json({ + message: 'Successfully synced connected-online', + }, { status: 200 }); + } catch (e) { + console.error("Error syncing connected-online", e); + return NextResponse.json({ + message: 'Failed to sync connected-online', + }, { status: 500 }); + } +}; diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 7e25449..0c6998d 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -10,7 +10,6 @@ import { getClientInstitution, getClientPerson, getConfidentiality, - getOrderEnteredByPerson, getPais, getPatient, getProviderInstitution, @@ -149,6 +148,7 @@ export async function getPrivateMessage(messageId: string) { const parsed = parser.parse(data); if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) { + console.error("Bad response", data); throw new Error(`Failed to get private message (id: ${messageId})`); } @@ -378,10 +378,13 @@ export async function syncPublicMessage( } } -// TODO use actual parameters export async function composeOrderXML( - /* chosenAnalysisElements?: number[], - chosenAnalyses?: number[], */ + person: { + idCode: string, + firstName: string, + lastName: string, + phone: string, + }, comment?: string, ) { const supabase = createCustomClient( @@ -512,11 +515,9 @@ export async function composeOrderXML( ${getProviderInstitution()} - ${getClientPerson()} - - ${getOrderEnteredByPerson()} + ${getClientPerson(person)} ${comment ?? ''} - ${getPatient(49610230861, 'Surname', 'First Name', '1996-10-23', 'N')} + ${getPatient(person)} ${getConfidentiality()} ${specimenSection.join('')} ${analysisSection?.join('')} diff --git a/lib/templates/medipost-order.ts b/lib/templates/medipost-order.ts index 9439438..51a9765 100644 --- a/lib/templates/medipost-order.ts +++ b/lib/templates/medipost-order.ts @@ -1,6 +1,7 @@ -import { DATE_TIME_FORMAT } from '@/lib/constants'; -import { Tables } from '@/packages/supabase/src/database.types'; import { format } from 'date-fns'; +import Isikukood from 'isikukood'; +import { Tables } from '@/packages/supabase/src/database.types'; +import { DATE_FORMAT, DATE_TIME_FORMAT } from '@/lib/constants'; const isProd = process.env.NODE_ENV === 'production'; @@ -48,47 +49,60 @@ export const getProviderInstitution = () => { `; }; -export const getClientPerson = () => { +export const getClientPerson = ({ + idCode, + firstName, + lastName, + phone, +}: { + idCode: string, + firstName: string, + lastName: string, + phone: string, +}) => { if (isProd) { // return correct data } return ` 1.3.6.1.4.1.28284.6.2.4.9 - D07907 - Eduard - Tsvetkov - +37258131202 + ${idCode} + ${lastName} + ${firstName} + ${phone} `; }; -export const getOrderEnteredPerson = () => { - if (isProd) { - // return correct data - } - return ` - 1.3.6.1.4.1.28284.6.2.4.9 - D07907 - Eduard - Tsvetkov - +37258131202 - `; -}; +// export const getOrderEnteredPerson = () => { +// if (isProd) { +// // return correct data +// } +// return ` +// 1.3.6.1.4.1.28284.6.2.4.9 +// D07907 +// Eduard +// Tsvetkov +// +37258131202 +// `; +// }; -export const getPatient = ( - idCode: number, - surname: string, +export const getPatient = ({ + idCode, + lastName, + firstName, +}: { + idCode: string, + lastName: string, firstName: string, - birthDate: string, - genderLetter: string, -) => { +}) => { + const isikukood = new Isikukood(idCode); return ` 1.3.6.1.4.1.28284.6.2.2.1 ${idCode} - ${surname} + ${lastName} ${firstName} - ${birthDate} + ${format(isikukood.getBirthday(), DATE_FORMAT)} 1.3.6.1.4.1.28284.6.2.3.16.2 - ${genderLetter} + ${isikukood.getGender()} `; }; @@ -106,19 +120,19 @@ export const getConfidentiality = () => { `; }; -export const getOrderEnteredByPerson = () => { - if (isProd) { - // return correct data - } - return ` - - 1.3.6.1.4.1.28284.6.2.4.9 - D07907 - Eduard - Tsvetkov - +37258131202 - `; -}; +// export const getOrderEnteredByPerson = () => { +// if (isProd) { +// // return correct data +// } +// return ` +// +// 1.3.6.1.4.1.28284.6.2.4.9 +// D07907 +// Eduard +// Tsvetkov +// +37258131202 +// `; +// }; export const getSpecimen = ( materialTypeOid: string, diff --git a/package.json b/package.json index 4106646..11b1f81 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,7 @@ "supabase:db:diff": "supabase db diff --schema auth --schema audit --schema medreport", "supabase:deploy": "supabase link --project-ref $SUPABASE_PROJECT_REF && supabase db push", "supabase:typegen": "supabase gen types typescript --local > ./packages/supabase/src/database.types.ts", - "supabase:db:dump:local": "supabase db dump --local --data-only", - "sync-analysis-groups:dev": "NODE_ENV=local ts-node jobs/sync-analysis-groups.ts", - "sync-connected-online:dev": "NODE_ENV=local ts-node jobs/sync-connected-online.ts" + "supabase:db:dump:local": "supabase db dump --local --data-only" }, "dependencies": { "@edge-csrf/nextjs": "2.5.3-cloudflare-rc1", From 12465e18fbf9dc1efbc96288ec155a3fc5e0aa59 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:51:21 +0300 Subject: [PATCH 02/30] feat(MED-131): update translations --- lib/i18n/i18n.settings.ts | 1 + public/locales/en/order-analysis.json | 4 ++++ public/locales/et/order-analysis.json | 4 ++++ 3 files changed, 9 insertions(+) create mode 100644 public/locales/en/order-analysis.json create mode 100644 public/locales/et/order-analysis.json diff --git a/lib/i18n/i18n.settings.ts b/lib/i18n/i18n.settings.ts index 97a8893..2d9dad2 100644 --- a/lib/i18n/i18n.settings.ts +++ b/lib/i18n/i18n.settings.ts @@ -36,6 +36,7 @@ export const defaultI18nNamespaces = [ 'product', 'booking', 'order-analysis-package', + 'order-analysis', 'cart', 'orders', ]; diff --git a/public/locales/en/order-analysis.json b/public/locales/en/order-analysis.json new file mode 100644 index 0000000..3cc4ea9 --- /dev/null +++ b/public/locales/en/order-analysis.json @@ -0,0 +1,4 @@ +{ + "title": "Select analysis", + "description": "Select the analysis that suits your needs" +} \ No newline at end of file diff --git a/public/locales/et/order-analysis.json b/public/locales/et/order-analysis.json new file mode 100644 index 0000000..f04be5e --- /dev/null +++ b/public/locales/et/order-analysis.json @@ -0,0 +1,4 @@ +{ + "title": "Vali analüüs", + "description": "Vali enda vajadustele sobiv analüüs" +} \ No newline at end of file From d7d089c11d64b0aed0fe35407fca7708b20b21d4 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:51:28 +0300 Subject: [PATCH 03/30] feat(MED-131): use existing supabase server admin client conf --- app/api/job/handler/sync-connected-online.ts | 20 +++------- app/api/job/handler/types.ts | 39 -------------------- lib/services/codes.service.ts | 8 ++++ lib/types/code.ts | 9 +++++ lib/types/connected-online.ts | 33 +++++++++++++++++ 5 files changed, 56 insertions(+), 53 deletions(-) delete mode 100644 app/api/job/handler/types.ts create mode 100644 lib/services/codes.service.ts create mode 100644 lib/types/code.ts diff --git a/app/api/job/handler/sync-connected-online.ts b/app/api/job/handler/sync-connected-online.ts index 2d0d9fb..829ba54 100644 --- a/app/api/job/handler/sync-connected-online.ts +++ b/app/api/job/handler/sync-connected-online.ts @@ -1,27 +1,19 @@ -import { createClient as createCustomClient } from '@supabase/supabase-js'; - import axios from 'axios'; -import type { IConnectedOnlineResponse_Search_Load } from './types'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +import type { ISearchLoadResponse } from '~/lib/types/connected-online'; export default async function syncConnectedOnline() { const isProd = process.env.NODE_ENV === 'production'; const baseUrl = process.env.CONNECTED_ONLINE_URL; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!baseUrl || !supabaseUrl || !supabaseServiceRoleKey) { + if (!baseUrl) { throw new Error('Could not access all necessary environment variables'); } - const supabase = createCustomClient(supabaseUrl, supabaseServiceRoleKey, { - auth: { - persistSession: false, - autoRefreshToken: false, - detectSessionInUrl: false, - }, - }); + const supabase = getSupabaseServerAdminClient(); try { const response = await axios.post<{ d: string }>(`${baseUrl}/Search_Load`, { @@ -31,7 +23,7 @@ export default async function syncConnectedOnline() { param: "{'Value':'|et|-1'}", // get all available services in Estonian }); - const responseData: IConnectedOnlineResponse_Search_Load = JSON.parse(response.data.d); + const responseData: ISearchLoadResponse = JSON.parse(response.data.d); if (responseData?.ErrorCode !== 0) { throw new Error('Failed to get Connected Online data'); diff --git a/app/api/job/handler/types.ts b/app/api/job/handler/types.ts deleted file mode 100644 index 0f32e9f..0000000 --- a/app/api/job/handler/types.ts +++ /dev/null @@ -1,39 +0,0 @@ -export interface IMedipostResponse_GetPublicMessageList { - code: number; - messages: { - messageId: string; - }[]; -} - -export interface IConnectedOnlineResponse_Search_Load { - Value: string; - Data: { - T_Lic: { - ID: number; - Name: string; - OnlineCanSelectWorker: boolean; - Email: string | null; - PersonalCodeRequired: boolean; - Phone: string | null; - }[]; - T_Service: { - ID: number; - ClinicID: number; - Code: string; - Description: string | null; - Display: string; - Duration: number; - HasFreeCodes: boolean; - Name: string; - NetoDuration: number; - OnlineHideDuration: number; - OnlineHidePrice: number; - Price: number; - PricePeriods: string | null; - RequiresPayment: boolean; - SyncID: string; - }[]; - }; - ErrorCode: number; - ErrorMessage: string; -} diff --git a/lib/services/codes.service.ts b/lib/services/codes.service.ts new file mode 100644 index 0000000..f3b19d3 --- /dev/null +++ b/lib/services/codes.service.ts @@ -0,0 +1,8 @@ +import { getSupabaseServerAdminClient } from "@kit/supabase/server-admin-client"; +import type { ICode } from "~/lib/types/code"; + +export const createCodes = async (codes: ICode[]) => { + await getSupabaseServerAdminClient() + .schema('medreport').from('codes') + .upsert(codes); +} diff --git a/lib/types/code.ts b/lib/types/code.ts new file mode 100644 index 0000000..a60ca8b --- /dev/null +++ b/lib/types/code.ts @@ -0,0 +1,9 @@ +export interface ICode { + hk_code: string; + hk_code_multiplier: number; + coefficient: number; + price: number; + analysis_group_id: number | null; + analysis_element_id: number | null; + analysis_id: number | null; +} diff --git a/lib/types/connected-online.ts b/lib/types/connected-online.ts index db6205d..57e95d4 100644 --- a/lib/types/connected-online.ts +++ b/lib/types/connected-online.ts @@ -224,3 +224,36 @@ export const ConfirmedLoadResponseSchema = z.object({ ErrorMessage: z.union([z.string(), z.null()]), }); export type ConfirmedLoadResponse = z.infer; + +export interface ISearchLoadResponse { + Value: string; + Data: { + T_Lic: { + ID: number; + Name: string; + OnlineCanSelectWorker: boolean; + Email: string | null; + PersonalCodeRequired: boolean; + Phone: string | null; + }[]; + T_Service: { + ID: number; + ClinicID: number; + Code: string; + Description: string | null; + Display: string; + Duration: number; + HasFreeCodes: boolean; + Name: string; + NetoDuration: number; + OnlineHideDuration: number; + OnlineHidePrice: number; + Price: number; + PricePeriods: string | null; + RequiresPayment: boolean; + SyncID: string; + }[]; + }; + ErrorCode: number; + ErrorMessage: string; +} From 8c4df731aaba9495a2af266d7962f10ca5b4c61e Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:51:38 +0300 Subject: [PATCH 04/30] feat(MED-131): show package analysis elements in comparison modal --- .../order-analysis-package/page.tsx | 3 +- .../_components/compare-packages-modal.tsx | 149 ++++++------------ .../_lib/server/load-analysis-packages.ts | 16 +- app/select-package/page.tsx | 3 +- lib/services/analysis-element.service.ts | 60 +++++++ lib/services/medipost.types.ts | 85 ++++++++++ .../src/lib/data/products.ts | 14 ++ 7 files changed, 224 insertions(+), 106 deletions(-) create mode 100644 lib/services/analysis-element.service.ts create mode 100644 lib/services/medipost.types.ts diff --git a/app/home/(user)/(dashboard)/order-analysis-package/page.tsx b/app/home/(user)/(dashboard)/order-analysis-package/page.tsx index 81ef7e8..5c7db8b 100644 --- a/app/home/(user)/(dashboard)/order-analysis-package/page.tsx +++ b/app/home/(user)/(dashboard)/order-analysis-package/page.tsx @@ -20,7 +20,7 @@ export const generateMetadata = async () => { }; async function OrderAnalysisPackagePage() { - const { analysisPackages, countryCode } = await loadAnalysisPackages(); + const { analysisElements, analysisPackages, countryCode } = await loadAnalysisPackages(); return ( @@ -29,6 +29,7 @@ async function OrderAnalysisPackagePage() { diff --git a/app/home/(user)/_components/compare-packages-modal.tsx b/app/home/(user)/_components/compare-packages-modal.tsx index ca2e2db..247c6ca 100644 --- a/app/home/(user)/_components/compare-packages-modal.tsx +++ b/app/home/(user)/_components/compare-packages-modal.tsx @@ -23,79 +23,8 @@ import { withI18n } from '~/lib/i18n/with-i18n'; import { PackageHeader } from '@/components/package-header'; import { InfoTooltip } from '@/components/ui/info-tooltip'; import { StoreProduct } from '@medusajs/types'; - -const dummyCards = [ - { - titleKey: 'product:standard.label', - price: 40, - nrOfAnalyses: 4, - tagColor: 'bg-cyan', - }, - { - titleKey: 'product:standardPlus.label', - price: 85, - nrOfAnalyses: 10, - tagColor: 'bg-warning', - }, - { - titleKey: 'product:premium.label', - price: 140, - nrOfAnalyses: '12+', - tagColor: 'bg-purple', - }, -]; - -const dummyRows = [ - { - analysisNameKey: 'product:clinicalBloodDraw.label', - tooltipContentKey: 'product:clinicalBloodDraw.description', - includedInStandard: 1, - includedInStandardPlus: 1, - includedInPremium: 1, - }, - { - analysisNameKey: 'product:crp.label', - tooltipContentKey: 'product:crp.description', - includedInStandard: 1, - includedInStandardPlus: 1, - includedInPremium: 1, - }, - { - analysisNameKey: 'product:ferritin.label', - tooltipContentKey: 'product:ferritin.description', - includedInStandard: 0, - includedInStandardPlus: 1, - includedInPremium: 1, - }, - { - analysisNameKey: 'product:vitaminD.label', - tooltipContentKey: 'product:vitaminD.description', - includedInStandard: 0, - includedInStandardPlus: 1, - includedInPremium: 1, - }, - { - analysisNameKey: 'product:glucose.label', - tooltipContentKey: 'product:glucose.description', - includedInStandard: 1, - includedInStandardPlus: 1, - includedInPremium: 1, - }, - { - analysisNameKey: 'product:alat.label', - tooltipContentKey: 'product:alat.description', - includedInStandard: 1, - includedInStandardPlus: 1, - includedInPremium: 1, - }, - { - analysisNameKey: 'product:ast.label', - tooltipContentKey: 'product:ast.description', - includedInStandard: 1, - includedInStandardPlus: 1, - includedInPremium: 1, - }, -]; +import type { AnalysisElement } from '~/lib/services/analysis-element.service'; +import { getAnalysisElementOriginalIds } from '@lib/data/products'; const CheckWithBackground = () => { return ( @@ -106,14 +35,28 @@ const CheckWithBackground = () => { }; const ComparePackagesModal = async ({ + analysisElements, analysisPackages, triggerElement, }: { + analysisElements: AnalysisElement[]; analysisPackages: StoreProduct[]; triggerElement: JSX.Element; }) => { const { t, language } = await createI18nServerInstance(); + const standardPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard')!; + const standardPlusPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard-plus')!; + const premiumPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'premium')!; + + if (!standardPackage || !standardPlusPackage || !premiumPackage) { + return null; + } + + const standardPackageAnalyses = await getAnalysisElementOriginalIds([standardPackage]); + const standardPlusPackageAnalyses = await getAnalysisElementOriginalIds([standardPlusPackage]); + const premiumPackageAnalyses = await getAnalysisElementOriginalIds([premiumPackage]); + return ( {triggerElement} @@ -138,7 +81,7 @@ const ComparePackagesModal = async ({

{t('product:healthPackageComparison.description')}

-
+
@@ -165,37 +108,41 @@ const ComparePackagesModal = async ({ - {dummyRows.map( + {analysisElements.map( ( { - analysisNameKey, - tooltipContentKey, - includedInStandard, - includedInStandardPlus, - includedInPremium, + analysis_name_lab: analysisName, + analysis_id_original: analysisId, }, index, - ) => ( - - - {t(analysisNameKey)}{' '} - } - /> - - - {!!includedInStandard && } - - - {!!includedInStandardPlus && } - - - {!!includedInPremium && } - - - ), - )} + ) => { + if (!analysisName) { + return null; + } + const includedInStandard = standardPackageAnalyses.includes(analysisId); + const includedInStandardPlus = standardPlusPackageAnalyses.includes(analysisId); + const includedInPremium = premiumPackageAnalyses.includes(analysisId); + return ( + + + {analysisName}{' '} + {/* } + /> */} + + + {includedInStandard && } + + + {(includedInStandard || includedInStandardPlus) && } + + + {(includedInStandard || includedInStandardPlus || includedInPremium) && } + + + ); + })}
diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts index 59da4ae..d46e8d1 100644 --- a/app/home/(user)/_lib/server/load-analysis-packages.ts +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -1,6 +1,8 @@ import { cache } from 'react'; -import { listProductTypes, listProducts, listRegions } from "@lib/data"; +import { getAnalysisElementOriginalIds, listProductTypes, listProducts } from "@lib/data/products"; +import { listRegions } from '@lib/data/regions'; +import { AnalysisElement, getAnalysisElements } from '~/lib/services/analysis-element.service'; async function countryCodesLoader() { const countryCodes = await listRegions().then((regions) => @@ -22,13 +24,21 @@ async function analysisPackagesLoader() { const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages'); if (!productType) { - return { analysisPackages: [], countryCode }; + return { analysisElements: [], analysisPackages: [], countryCode }; } const { response } = await listProducts({ countryCode, queryParams: { limit: 100, "type_id[0]": productType.id }, }); - return { analysisPackages: response.products, countryCode }; + const analysisPackages = response.products; + let analysisElements: AnalysisElement[] = []; + const analysisElementOriginalIds = await getAnalysisElementOriginalIds(analysisPackages); + + if (analysisElementOriginalIds.length) { + analysisElements = await getAnalysisElements({ originalIds: analysisElementOriginalIds }); + } + + return { analysisElements, analysisPackages, countryCode }; } export const loadAnalysisPackages = cache(analysisPackagesLoader); diff --git a/app/select-package/page.tsx b/app/select-package/page.tsx index 3ebc34b..5549cc2 100644 --- a/app/select-package/page.tsx +++ b/app/select-package/page.tsx @@ -24,7 +24,7 @@ export const generateMetadata = async () => { }; async function SelectPackagePage() { - const { analysisPackages, countryCode } = await loadAnalysisPackages(); + const { analysisElements, analysisPackages, countryCode } = await loadAnalysisPackages(); return (
@@ -34,6 +34,7 @@ async function SelectPackagePage() { diff --git a/lib/services/analysis-element.service.ts b/lib/services/analysis-element.service.ts new file mode 100644 index 0000000..783a348 --- /dev/null +++ b/lib/services/analysis-element.service.ts @@ -0,0 +1,60 @@ +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { Json, Tables } from '@kit/supabase/database'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; +import type { IMaterialGroup, IUuringElement } from './medipost.types'; + +export type AnalysisElement = Tables<{ schema: 'medreport' }, 'analysis_elements'> & { + analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>; +}; + +export async function getAnalysisElements({ + originalIds, +}: { + originalIds: string[] +}) { + const { data: analysisElements } = await getSupabaseServerClient() + .schema('medreport') + .from('analysis_elements') + .select(`*, analysis_groups(*)`) + .in('analysis_id_original', [...new Set(originalIds)]) + .order('order', { ascending: true }); + + return analysisElements ?? []; +} + +export async function createAnalysisElement({ + analysisElement, + analysisGroupId, + materialGroups, +}: { + analysisElement: IUuringElement; + analysisGroupId: number; + materialGroups: IMaterialGroup[]; +}) { + const { data: insertedAnalysisElement, error } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('analysis_elements') + .upsert( + { + analysis_id_oid: analysisElement.UuringIdOID, + analysis_id_original: analysisElement.UuringId, + tehik_short_loinc: analysisElement.TLyhend, + tehik_loinc_name: analysisElement.KNimetus, + analysis_name_lab: analysisElement.UuringNimi, + order: analysisElement.Jarjekord, + parent_analysis_group_id: analysisGroupId, + material_groups: materialGroups as unknown as Json[], + }, + { onConflict: 'analysis_id_original', ignoreDuplicates: false }, + ) + .select('id'); + + const id = insertedAnalysisElement?.[0]?.id; + if (error || !id) { + throw new Error( + `Failed to insert analysis element (id: ${analysisElement.UuringId}), error: ${error?.message}`, + ); + } + + return id; +} diff --git a/lib/services/medipost.types.ts b/lib/services/medipost.types.ts new file mode 100644 index 0000000..3f14de8 --- /dev/null +++ b/lib/services/medipost.types.ts @@ -0,0 +1,85 @@ +export interface IUuringElement { + UuringIdOID: string; + UuringId: string; + TLyhend: string; + KNimetus: string; + UuringNimi: string; + Jarjekord: number; + Kood: { + HkKood: string; + HkKoodiKordaja: number; + Koefitsient: number; + Hind: number; + }[]; + UuringuElement: { + UuringIdOID: string; + UuringId: string; + TLyhend: string; + KNimetus: string; + UuringNimi: string; + Jarjekord: number; + Kood: { + HkKood: string; + HkKoodiKordaja: number; + Koefitsient: number; + Hind: number; + }[]; + }[]; +} + +export interface IMaterialGroup { + id: string; + name: string; + order: number; +} + +export interface IMedipostPublicMessageDataParsed { + ANSWER: { + CODE: number; + MESSAGE: string; + }; + Saadetis: { + Teenused: { + Teostaja: { + UuringuGrupp: { + UuringuGruppId: string; + UuringuGruppNimi: string; + UuringuGruppJarjekord: number; + Kood: { + HkKood: string; + HkKoodiKordaja: number; + Koefitsient: number; + Hind: number; + }[]; + Uuring: { + UuringId: string; + UuringNimi: string; + UuringJarjekord: number; + UuringuElement: { + UuringIdOID: string; + UuringId: string; + TLyhend: string; + KNimetus: string; + UuringNimi: string; + Jarjekord: number; + Kood: { + HkKood: string; + HkKoodiKordaja: number; + Koefitsient: number; + Hind: number; + }[]; + UuringuElement: IUuringElement; + }[]; + MaterjalideGrupp: IMaterialGroup[]; + Kood: { + HkKood: string; + HkKoodiKordaja: number; + Koefitsient: number; + Hind: number; + }[]; + }[]; + }[]; + }[]; + }; + }; +} diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts index 810d205..2548058 100644 --- a/packages/features/medusa-storefront/src/lib/data/products.ts +++ b/packages/features/medusa-storefront/src/lib/data/products.ts @@ -85,6 +85,20 @@ export const listProducts = async ({ }) } +export const getAnalysisElementOriginalIds = async (products: HttpTypes.StoreProduct[]) => { + return products + .flatMap(({ metadata }) => { + const value = metadata?.analysisElementOriginalIds; + try { + return JSON.parse(value as string); + } catch (e) { + console.error("Failed to parse analysisElementOriginalIds from analysis package, possibly invalid format", e); + return []; + } + }) + .filter(Boolean) as string[]; +} + /** * This will fetch 100 products to the Next.js cache and sort them based on the sortBy parameter. * It will then return the paginated products based on the page and limit parameters. From b665678dbb32bdea75dba058fb7ff8b1c800a3d1 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:51:49 +0300 Subject: [PATCH 05/30] feat(MED-131): display analyses options and add to cart --- .../(dashboard)/order-analysis/page.tsx | 5 ++ .../_components/order-analyses-cards.tsx | 76 +++++++++++++++++++ app/home/(user)/_lib/server/load-analyses.ts | 41 ++++++++++ .../src/lib/data/categories.ts | 24 ++++-- .../src/lib/data/products.ts | 2 +- 5 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 app/home/(user)/_components/order-analyses-cards.tsx create mode 100644 app/home/(user)/_lib/server/load-analyses.ts diff --git a/app/home/(user)/(dashboard)/order-analysis/page.tsx b/app/home/(user)/(dashboard)/order-analysis/page.tsx index e8d28ad..942f78e 100644 --- a/app/home/(user)/(dashboard)/order-analysis/page.tsx +++ b/app/home/(user)/(dashboard)/order-analysis/page.tsx @@ -3,6 +3,8 @@ import { withI18n } from '~/lib/i18n/with-i18n'; import { PageBody } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; import { HomeLayoutPageHeader } from '../../_components/home-page-header'; +import { loadAnalyses } from '../../_lib/server/load-analyses'; +import OrderAnalysesCards from '../../_components/order-analyses-cards'; export const generateMetadata = async () => { const { t } = await createI18nServerInstance(); @@ -13,6 +15,8 @@ export const generateMetadata = async () => { }; async function OrderAnalysisPage() { + const { analyses, countryCode } = await loadAnalyses(); + return ( <> } /> + ); diff --git a/app/home/(user)/_components/order-analyses-cards.tsx b/app/home/(user)/_components/order-analyses-cards.tsx new file mode 100644 index 0000000..bc4a690 --- /dev/null +++ b/app/home/(user)/_components/order-analyses-cards.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { HeartPulse, ShoppingCart } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; +import { + Card, + CardHeader, + CardFooter, +} from '@kit/ui/card'; +import { StoreProduct, StoreProductVariant } from '@medusajs/types'; +import { useState } from 'react'; +import { handleAddToCart } from '~/lib/services/medusaCart.service'; +import { useRouter } from 'next/navigation'; + +export default function OrderAnalysesCards({ + analyses, + countryCode, +}: { + analyses: StoreProduct[]; + countryCode: string; +}) { + const router = useRouter(); + + const [isAddingToCart, setIsAddingToCart] = useState(false); + const handleSelect = async (selectedVariant: StoreProductVariant) => { + if (!selectedVariant?.id) return null + + setIsAddingToCart(true); + await handleAddToCart({ + selectedVariant, + countryCode, + }); + setIsAddingToCart(false); + router.push('/home/cart'); + } + + return ( +
+ {analyses.map(({ + title, + variants + }) => ( + + +
+ +
+
+ +
+ +
+
+ {title} +
+
+
+ ))} +
+ ); +} diff --git a/app/home/(user)/_lib/server/load-analyses.ts b/app/home/(user)/_lib/server/load-analyses.ts new file mode 100644 index 0000000..d6bc2e2 --- /dev/null +++ b/app/home/(user)/_lib/server/load-analyses.ts @@ -0,0 +1,41 @@ +import { cache } from 'react'; + +import { listProductTypes } from "@lib/data/products"; +import { listRegions } from '@lib/data/regions'; +import { getProductCategories } from '@lib/data/categories'; + +async function countryCodesLoader() { + const countryCodes = await listRegions().then((regions) => + regions?.map((r) => r.countries?.map((c) => c.iso_2)).flat(), + ); + return countryCodes ?? []; +} +export const loadCountryCodes = cache(countryCodesLoader); + +async function productCategoriesLoader() { + const productCategories = await getProductCategories({ fields: "*products, *products.variants" }); + return productCategories.product_categories ?? []; +} +export const loadProductCategories = cache(productCategoriesLoader); + +async function productTypesLoader() { + const { productTypes } = await listProductTypes(); + return productTypes ?? []; +} +export const loadProductTypes = cache(productTypesLoader); + +async function analysesLoader() { + const [countryCodes, productCategories] = await Promise.all([ + loadCountryCodes(), + loadProductCategories(), + ]); + const countryCode = countryCodes[0]!; + + const category = productCategories.find(({ metadata }) => metadata?.page === 'order-analysis'); + + return { + analyses: category?.products ?? [], + countryCode, + } +} +export const loadAnalyses = cache(analysesLoader); diff --git a/packages/features/medusa-storefront/src/lib/data/categories.ts b/packages/features/medusa-storefront/src/lib/data/categories.ts index f847745..7b3987d 100644 --- a/packages/features/medusa-storefront/src/lib/data/categories.ts +++ b/packages/features/medusa-storefront/src/lib/data/categories.ts @@ -27,8 +27,22 @@ export const listCategories = async (query?: Record) => { } export const getCategoryByHandle = async (categoryHandle: string[]) => { - const handle = `${categoryHandle.join("/")}` + const { product_categories } = await getProductCategories({ + handle: `${categoryHandle.join("/")}`, + limit: 1, + }); + return product_categories[0]; +} +export const getProductCategories = async ({ + handle, + limit, + fields = "*category_children, *products", +}: { + handle?: string; + limit?: number; + fields?: string; +} = {}) => { const next = { ...(await getCacheOptions("categories")), } @@ -38,12 +52,12 @@ export const getCategoryByHandle = async (categoryHandle: string[]) => { `/store/product-categories`, { query: { - fields: "*category_children, *products", + fields, handle, + limit, }, next, - cache: "force-cache", + //cache: "force-cache", } - ) - .then(({ product_categories }) => product_categories[0]) + ); } diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts index 2548058..3608d2d 100644 --- a/packages/features/medusa-storefront/src/lib/data/products.ts +++ b/packages/features/medusa-storefront/src/lib/data/products.ts @@ -159,7 +159,7 @@ export const listProductTypes = async (): Promise<{ productTypes: HttpTypes.Stor "/store/product-types", { next, - cache: "force-cache", + //cache: "force-cache", query: { fields: "id,value,metadata", }, From ee60a78335212d978c3fd6dd3d95d2c36f3bfc31 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:52:09 +0300 Subject: [PATCH 06/30] feat(MED-131): update analyses sync to medusa store --- .../job/handler/sync-analysis-groups-store.ts | 220 ++++++++++++++++++ app/api/job/handler/sync-analysis-groups.ts | 200 ++++------------ .../job/sync-analysis-groups-store/route.ts | 27 +++ lib/services/analyses.service.ts | 99 ++++++++ lib/services/analysis-element.service.ts | 17 +- lib/services/analysis-group.service.ts | 40 ++++ lib/services/medipost.service.ts | 25 +- lib/services/sync-entries.service.ts | 18 ++ 8 files changed, 460 insertions(+), 186 deletions(-) create mode 100644 app/api/job/handler/sync-analysis-groups-store.ts create mode 100644 app/api/job/sync-analysis-groups-store/route.ts create mode 100644 lib/services/analyses.service.ts create mode 100644 lib/services/analysis-group.service.ts create mode 100644 lib/services/sync-entries.service.ts diff --git a/app/api/job/handler/sync-analysis-groups-store.ts b/app/api/job/handler/sync-analysis-groups-store.ts new file mode 100644 index 0000000..151d72e --- /dev/null +++ b/app/api/job/handler/sync-analysis-groups-store.ts @@ -0,0 +1,220 @@ +import Medusa from "@medusajs/js-sdk" +import type { AdminProductCategory } from "@medusajs/types"; +import { listProductTypes } from "@lib/data/products"; +import { getAnalysisElements } from "~/lib/services/analysis-element.service"; +import { getAnalysisGroups } from "~/lib/services/analysis-group.service"; +import { createMedusaSyncFailEntry, createMedusaSyncSuccessEntry } from "~/lib/services/analyses.service"; + +const SYNLAB_SERVICES_CATEGORY_HANDLE = 'synlab-services'; +const SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE = 'synlab-analysis'; + +const BASE_ANALYSIS_PRODUCT_HANDLE = 'analysis-base'; + +const getAdminSdk = () => { + const medusaBackendUrl = process.env.MEDUSA_BACKEND_PUBLIC_URL!; + const medusaPublishableApiKey = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!; + const key = process.env.MEDUSA_SECRET_API_KEY!; + + if (!medusaBackendUrl || !medusaPublishableApiKey) { + throw new Error('Medusa environment variables not set'); + } + return new Medusa({ + baseUrl: medusaBackendUrl, + debug: process.env.NODE_ENV === 'development', + apiKey: key, + }); +} + +async function createProductCategories({ + medusa, +}: { + medusa: Medusa; +}) { + const existingProductCategories = await medusa.admin.productCategory.list(); + const parentCategory = existingProductCategories.product_categories.find(({ handle }) => handle === SYNLAB_SERVICES_CATEGORY_HANDLE); + + if (!parentCategory) { + throw new Error('Parent category not found'); + } + + const analysisGroups = await getAnalysisGroups(); + if (!analysisGroups) { + throw new Error('Analysis groups not found'); + } + + const createdCategories: AdminProductCategory[] = []; + for (const analysisGroup of analysisGroups) { + console.info(`Processing analysis group '${analysisGroup.name}'`); + + const isExisting = existingProductCategories.product_categories.find(({ name }) => name === analysisGroup.name); + const isNewlyCreated = createdCategories.find(({ name }) => name === analysisGroup.name); + if (isExisting || isNewlyCreated) { + console.info(`Analysis group '${analysisGroup.name}' already exists`); + continue; + } + + const createResponse = await medusa.admin.productCategory.create({ + name: analysisGroup.name, + handle: analysisGroup.name, + parent_category_id: parentCategory.id, + is_active: true, + metadata: { + analysisGroupOriginalId: analysisGroup.original_id, + analysisGroupId: analysisGroup.id, + }, + }); + console.info(`Successfully created category, id=${createResponse.product_category.id}`); + createdCategories.push(createResponse.product_category); + } +} + +/** + * In case a reset is needed + */ +async function deleteProducts({ + medusa, +}: { + medusa: Medusa; +}) { + const { product_categories: allCategories } = await medusa.admin.productCategory.list(); + const { products: existingProducts } = await medusa.admin.product.list({ + category_id: allCategories.map(({ id }) => id), + }); + + for (const product of existingProducts) { + await medusa.admin.product.delete(product.id); + } +} + +async function getAnalysisPackagesType() { + const { productTypes } = await listProductTypes(); + const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === SYNLAB_ANALYSIS_PRODUCT_TYPE_HANDLE); + if (!analysisPackagesType) { + throw new Error('Synlab analysis packages type not found'); + } + return analysisPackagesType; +} + +async function getProductDefaultFields({ + medusa, +}: { + medusa: Medusa; +}) { + const baseProductsResponse = await medusa.admin.product.list({ handle: BASE_ANALYSIS_PRODUCT_HANDLE }) + const baseProduct = baseProductsResponse.products[0]; + if (!baseProduct) { + throw new Error('Base product not found'); + } + const defaultSalesChannels = baseProduct.sales_channels; + if (!Array.isArray(defaultSalesChannels)) { + throw new Error('Base analysis product has no required sales channels'); + } + const defaultProductOption = baseProduct.options; + if (!Array.isArray(defaultProductOption)) { + throw new Error('Base analysis product has no required options'); + } + const defaultProductVariant = baseProduct.variants?.[0]; + if (!defaultProductVariant) { + throw new Error('Base analysis product has no required variant'); + } + + return { + defaultSalesChannels, + defaultProductOption, + defaultProductVariant, + } +} + +async function createProducts({ + medusa, +}: { + medusa: Medusa; +}) { + const { product_categories: allCategories } = await medusa.admin.productCategory.list(); + + const [ + { products: existingProducts }, + analysisElements, + analysisPackagesType, + { + defaultSalesChannels, + defaultProductOption, + defaultProductVariant, + } + ] = await Promise.all([ + medusa.admin.product.list({ + category_id: allCategories.map(({ id }) => id), + }), + getAnalysisElements(), + getAnalysisPackagesType(), + getProductDefaultFields({ medusa }), + ]) + + for (const analysisElement of analysisElements) { + const { analysis_id_original: originalId } = analysisElement; + const isExisting = existingProducts.find(({ metadata }) => metadata?.analysisIdOriginal === originalId); + if (isExisting) { + console.info(`Analysis element '${analysisElement.analysis_name_lab}' already exists`); + continue; + } + const { analysis_name_lab: name } = analysisElement; + if (!name) { + console.error(`Analysis element '${originalId}' has no name`); + continue; + } + + const category = allCategories.find(({ metadata }) => metadata?.analysisGroupId === analysisElement.parent_analysis_group_id); + if (!category) { + console.error(`Category not found for analysis element '${name}'`); + continue; + } + + const createResponse = await medusa.admin.product.create({ + title: name, + handle: `analysis-element-${analysisElement.id}`, + categories: [{ id: category.id }], + options: defaultProductOption.map(({ id, title, values }) => ({ + id, + title, + values: values?.map(({ value }) => value) ?? [], + })), + metadata: { + analysisIdOriginal: originalId, + }, + is_giftcard: false, + discountable: false, + status: 'published', + sales_channels: defaultSalesChannels.map(({ id }) => ({ id })), + variants: [ + { + title: defaultProductVariant.title!, + prices: defaultProductVariant.prices!, + manage_inventory: false, + }, + ], + type_id: analysisPackagesType.id, + }); + console.info(`Successfully created product, id=${createResponse.product.id}`); + } +} + +export default async function syncAnalysisGroupsStore() { + const medusa = getAdminSdk(); + + try { + await createProductCategories({ medusa }); + + // await deleteProducts({ medusa }); + // return; + + await createProducts({ medusa }); + + await createMedusaSyncSuccessEntry(); + } catch (e) { + await createMedusaSyncFailEntry(JSON.stringify(e)); + console.error(e); + throw new Error( + `Failed to sync analyses to Medusa, error: ${JSON.stringify(e)}`, + ); + } +} diff --git a/app/api/job/handler/sync-analysis-groups.ts b/app/api/job/handler/sync-analysis-groups.ts index c0d56d8..fa4dc3e 100644 --- a/app/api/job/handler/sync-analysis-groups.ts +++ b/app/api/job/handler/sync-analysis-groups.ts @@ -1,95 +1,42 @@ -import { createClient as createCustomClient } from '@supabase/supabase-js'; - import axios from 'axios'; -import { format } from 'date-fns'; import { XMLParser } from 'fast-xml-parser'; -import { IMedipostResponse_GetPublicMessageList } from './types'; - -function getLatestMessage(messages: IMedipostResponse_GetPublicMessageList['messages']): IMedipostResponse_GetPublicMessageList['messages'][number] | null { - if (!messages?.length) { - return null; - } - - return messages.reduce((prev, current) => - Number(prev.messageId) > Number(current.messageId) ? prev : current, - ); -} +import fs from 'fs'; +import { createAnalysisGroup } from '~/lib/services/analysis-group.service'; +import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost.types'; +import { createAnalysis, createNoDataReceivedEntry, createNoNewDataReceivedEntry, createSyncFailEntry, createSyncSuccessEntry } from '~/lib/services/analyses.service'; +import { getLastCheckedDate } from '~/lib/services/sync-entries.service'; +import { createAnalysisElement } from '~/lib/services/analysis-element.service'; +import { createCodes } from '~/lib/services/codes.service'; +import { getLatestPublicMessageListItem } from '~/lib/services/medipost.service'; +import type { ICode } from '~/lib/types/code'; function toArray(input?: T | T[] | null): T[] { if (!input) return []; return Array.isArray(input) ? input : [input]; } +const WRITE_XML_TO_FILE = false as boolean; + export default async function syncAnalysisGroups() { const baseUrl = process.env.MEDIPOST_URL; const user = process.env.MEDIPOST_USER; const password = process.env.MEDIPOST_PASSWORD; const sender = process.env.MEDIPOST_MESSAGE_SENDER; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if ( - !baseUrl || - !supabaseUrl || - !supabaseServiceRoleKey || - !user || - !password || - !sender - ) { + if (!baseUrl || !user || !password || !sender) { throw new Error('Could not access all necessary environment variables'); } - const supabase = createCustomClient(supabaseUrl, supabaseServiceRoleKey, { - auth: { - persistSession: false, - autoRefreshToken: false, - detectSessionInUrl: false, - }, - }); - try { console.info('Getting latest public message id'); - const { data: lastChecked } = await supabase - .schema('audit') - .from('sync_entries') - .select('created_at') - .eq('status', 'SUCCESS') - .order('created_at') - .limit(1); - const lastEntry = lastChecked?.[0]; - const lastCheckedDate = lastEntry - ? format(lastEntry.created_at, 'yyyy-MM-dd HH:mm:ss') - : null; + const lastCheckedDate = await getLastCheckedDate(); - console.info('Getting public message list'); - const { data, status } = await axios.get(baseUrl, { - params: { - Action: 'GetPublicMessageList', - User: user, - Password: password, - //Sender: sender, - // ...(lastCheckedDate && { LastChecked: lastCheckedDate }), - MessageType: 'Teenus', - }, - }); - - if (!data || status !== 200 || data.code !== 0) { - console.error("Failed to get public message list, status: ", status, data); - throw new Error('Failed to get public message list'); - } - - if (!data.messages?.length) { + const latestMessage = await getLatestPublicMessageListItem(); + if (!latestMessage) { console.info('No new data received'); - return supabase.schema('audit').from('sync_entries').insert({ - operation: 'ANALYSES_SYNC', - comment: 'No new data received', - status: 'SUCCESS', - changed_by_role: 'service_role', - }); + await createNoNewDataReceivedEntry(); + return; } - - // GET PUBLIC MESSAGE WITH GIVEN ID - const latestMessage = getLatestMessage(data?.messages)!; console.info('Getting public message with id: ', latestMessage.messageId); const { data: publicMessageData } = await axios.get(baseUrl, { @@ -104,8 +51,12 @@ export default async function syncAnalysisGroups() { }, }); + if (WRITE_XML_TO_FILE) { + fs.writeFileSync('public-messages-list-response.xml', publicMessageData); + } + const parser = new XMLParser({ ignoreAttributes: false }); - const parsed = parser.parse(publicMessageData); + const parsed: IMedipostPublicMessageDataParsed = parser.parse(publicMessageData); if (parsed.ANSWER?.CODE && parsed.ANSWER?.CODE !== 0) { throw new Error( @@ -121,36 +72,19 @@ export default async function syncAnalysisGroups() { ); if (!parsed || !analysisGroups.length) { - return supabase.schema('audit').from('sync_entries').insert({ - operation: 'ANALYSES_SYNC', - comment: 'No data received', - status: 'FAIL', - changed_by_role: 'service_role', - }); + console.info('No analysis groups data received'); + await createNoDataReceivedEntry(); + return; } - const codes: any[] = []; + const codes: ICode[] = []; for (const analysisGroup of analysisGroups) { // SAVE ANALYSIS GROUP - const { data: insertedAnalysisGroup, error } = await supabase - .schema('medreport') - .from('analysis_groups') - .upsert( - { - original_id: analysisGroup.UuringuGruppId, - name: analysisGroup.UuringuGruppNimi, - order: analysisGroup.UuringuGruppJarjekord, - }, - { onConflict: 'original_id', ignoreDuplicates: false }, - ) - .select('id'); - - if (error || !insertedAnalysisGroup[0]?.id) { - throw new Error( - `Failed to insert analysis group (id: ${analysisGroup.UuringuGruppId}), error: ${error?.message}`, - ); - } - const analysisGroupId = insertedAnalysisGroup[0].id; + const analysisGroupId = await createAnalysisGroup({ + id: analysisGroup.UuringuGruppId, + name: analysisGroup.UuringuGruppNimi, + order: analysisGroup.UuringuGruppJarjekord, + }); const analysisGroupCodes = toArray(analysisGroup.Kood); codes.push( @@ -170,31 +104,11 @@ export default async function syncAnalysisGroups() { for (const item of analysisGroupItems) { const analysisElement = item.UuringuElement; - const { data: insertedAnalysisElement, error } = await supabase - .schema('medreport') - .from('analysis_elements') - .upsert( - { - analysis_id_oid: analysisElement.UuringIdOID, - analysis_id_original: analysisElement.UuringId, - tehik_short_loinc: analysisElement.TLyhend, - tehik_loinc_name: analysisElement.KNimetus, - analysis_name_lab: analysisElement.UuringNimi, - order: analysisElement.Jarjekord, - parent_analysis_group_id: analysisGroupId, - material_groups: toArray(item.MaterjalideGrupp), - }, - { onConflict: 'analysis_id_original', ignoreDuplicates: false }, - ) - .select('id'); - - if (error || !insertedAnalysisElement[0]?.id) { - throw new Error( - `Failed to insert analysis element (id: ${analysisElement.UuringId}), error: ${error?.message}`, - ); - } - - const insertedAnalysisElementId = insertedAnalysisElement[0].id; + const insertedAnalysisElementId = await createAnalysisElement({ + analysisElement, + analysisGroupId, + materialGroups: toArray(item.MaterjalideGrupp), + }); if (analysisElement.Kood) { const analysisElementCodes = toArray(analysisElement.Kood); @@ -214,30 +128,8 @@ export default async function syncAnalysisGroups() { const analyses = analysisElement.UuringuElement; if (analyses?.length) { for (const analysis of analyses) { - const { data: insertedAnalysis, error } = await supabase - .schema('medreport') - .from('analyses') - .upsert( - { - analysis_id_oid: analysis.UuringIdOID, - analysis_id_original: analysis.UuringId, - tehik_short_loinc: analysis.TLyhend, - tehik_loinc_name: analysis.KNimetus, - analysis_name_lab: analysis.UuringNimi, - order: analysis.Jarjekord, - parent_analysis_element_id: insertedAnalysisElementId, - }, - { onConflict: 'analysis_id_original', ignoreDuplicates: false }, - ) - .select('id'); + const insertedAnalysisId = await createAnalysis(analysis, analysisGroupId); - if (error || !insertedAnalysis[0]?.id) { - throw new Error( - `Failed to insert analysis (id: ${analysis.UuringId}) error: ${error?.message}`, - ); - } - - const insertedAnalysisId = insertedAnalysis[0].id; if (analysis.Kood) { const analysisCodes = toArray(analysis.Kood); @@ -259,24 +151,12 @@ export default async function syncAnalysisGroups() { } console.info('Inserting codes'); - await supabase.schema('medreport').from('codes').upsert(codes); + await createCodes(codes); console.info('Inserting sync entry'); - await supabase.schema('audit').from('sync_entries').insert({ - operation: 'ANALYSES_SYNC', - status: 'SUCCESS', - changed_by_role: 'service_role', - }); + await createSyncSuccessEntry(); } catch (e) { - await supabase - .schema('audit') - .from('sync_entries') - .insert({ - operation: 'ANALYSES_SYNC', - status: 'FAIL', - comment: JSON.stringify(e), - changed_by_role: 'service_role', - }); + await createSyncFailEntry(JSON.stringify(e)); console.error(e); throw new Error( `Failed to sync public message data, error: ${JSON.stringify(e)}`, diff --git a/app/api/job/sync-analysis-groups-store/route.ts b/app/api/job/sync-analysis-groups-store/route.ts new file mode 100644 index 0000000..8f05834 --- /dev/null +++ b/app/api/job/sync-analysis-groups-store/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import loadEnv from "../handler/load-env"; +import validateApiKey from "../handler/validate-api-key"; +import syncAnalysisGroupsStore from "../handler/sync-analysis-groups-store"; + +export const GET = async (request: NextRequest) => { + loadEnv(); + + try { + validateApiKey(request); + } catch (e) { + return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); + } + + try { + await syncAnalysisGroupsStore(); + console.info("Successfully synced analysis groups store"); + return NextResponse.json({ + message: 'Successfully synced analysis groups store', + }, { status: 200 }); + } catch (e) { + console.error("Error syncing analysis groups store", e); + return NextResponse.json({ + message: 'Failed to sync analysis groups store', + }, { status: 500 }); + } +}; diff --git a/lib/services/analyses.service.ts b/lib/services/analyses.service.ts new file mode 100644 index 0000000..ea56526 --- /dev/null +++ b/lib/services/analyses.service.ts @@ -0,0 +1,99 @@ +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; +import type { IUuringElement } from "./medipost.types"; + +export const createAnalysis = async ( + analysis: IUuringElement, + insertedAnalysisElementId: number, +) => { + const { data: insertedAnalysis, error } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('analyses') + .upsert( + { + analysis_id_oid: analysis.UuringIdOID, + analysis_id_original: analysis.UuringId, + tehik_short_loinc: analysis.TLyhend, + tehik_loinc_name: analysis.KNimetus, + analysis_name_lab: analysis.UuringNimi, + order: analysis.Jarjekord, + parent_analysis_element_id: insertedAnalysisElementId, + }, + { onConflict: 'analysis_id_original', ignoreDuplicates: false }, + ) + .select('id'); + const insertedAnalysisId = insertedAnalysis?.[0]?.id as number; + + if (error || !insertedAnalysisId) { + throw new Error( + `Failed to insert analysis (id: ${analysis.UuringId}) error: ${error?.message}`, + ); + } + + return insertedAnalysisId; +} + +const createSyncEntry = async ({ + operation, + status, + comment, +}: { + operation: 'ANALYSES_SYNC' | 'ANALYSIS_GROUPS_SYNC' | 'ANALYSES_MEDUSA_SYNC'; + status: 'SUCCESS' | 'FAIL'; + comment?: string; +}) => { + await getSupabaseServerAdminClient() + .schema('audit').from('sync_entries') + .insert({ + operation, + status, + changed_by_role: 'service_role', + comment, + }); +} + +export const createNoNewDataReceivedEntry = async () => { + await createSyncEntry({ + operation: 'ANALYSES_SYNC', + status: 'SUCCESS', + comment: 'No new data received', + }); +} + +export const createNoDataReceivedEntry = async () => { + await createSyncEntry({ + operation: 'ANALYSES_SYNC', + status: 'SUCCESS', + comment: 'No data received', + }); +} + +export const createSyncFailEntry = async (error: string) => { + await createSyncEntry({ + operation: 'ANALYSES_SYNC', + status: 'FAIL', + comment: error, + }); +} + +export const createSyncSuccessEntry = async () => { + await createSyncEntry({ + operation: 'ANALYSES_SYNC', + status: 'SUCCESS', + }); +} + +export const createMedusaSyncFailEntry = async (error: string) => { + await createSyncEntry({ + operation: 'ANALYSES_MEDUSA_SYNC', + status: 'FAIL', + comment: error, + }); +} + + +export const createMedusaSyncSuccessEntry = async () => { + await createSyncEntry({ + operation: 'ANALYSES_MEDUSA_SYNC', + status: 'SUCCESS', + }); +} diff --git a/lib/services/analysis-element.service.ts b/lib/services/analysis-element.service.ts index 783a348..6dddaee 100644 --- a/lib/services/analysis-element.service.ts +++ b/lib/services/analysis-element.service.ts @@ -7,18 +7,21 @@ export type AnalysisElement = Tables<{ schema: 'medreport' }, 'analysis_elements analysis_groups: Tables<{ schema: 'medreport' }, 'analysis_groups'>; }; -export async function getAnalysisElements({ - originalIds, -}: { - originalIds: string[] -}) { - const { data: analysisElements } = await getSupabaseServerClient() +export async function getAnalysisElements({ originalIds }: { + originalIds?: string[] +} = {}) { + const query = getSupabaseServerClient() .schema('medreport') .from('analysis_elements') .select(`*, analysis_groups(*)`) - .in('analysis_id_original', [...new Set(originalIds)]) .order('order', { ascending: true }); + if (Array.isArray(originalIds)) { + query.in('analysis_id_original', [...new Set(originalIds)]); + } + + const { data: analysisElements } = await query; + return analysisElements ?? []; } diff --git a/lib/services/analysis-group.service.ts b/lib/services/analysis-group.service.ts new file mode 100644 index 0000000..17e323c --- /dev/null +++ b/lib/services/analysis-group.service.ts @@ -0,0 +1,40 @@ +import { getSupabaseServerAdminClient } from "@kit/supabase/server-admin-client"; + +export const createAnalysisGroup = async ( + analysisGroup: { + id: string; + name: string; + order: number; + } +) => { + const { data: insertedAnalysisGroup, error } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('analysis_groups') + .upsert( + { + original_id: analysisGroup.id, + name: analysisGroup.name, + order: analysisGroup.order, + }, + { onConflict: 'original_id', ignoreDuplicates: false }, + ) + .select('id'); + const analysisGroupId = insertedAnalysisGroup?.[0]?.id as number; + + if (error || !analysisGroupId) { + throw new Error( + `Failed to insert analysis group (id: ${analysisGroup.id}), error: ${error?.message}`, + ); + } + + return analysisGroupId; +} + +export const getAnalysisGroups = async () => { + const { data: analysisGroups } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('analysis_groups') + .select('*'); + + return analysisGroups; +} diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 0c6998d..aad9655 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -33,6 +33,7 @@ import { XMLParser } from 'fast-xml-parser'; import { uniqBy } from 'lodash'; import { Tables } from '@kit/supabase/database'; +import { createAnalysisGroup } from './analysis-group.service'; const BASE_URL = process.env.MEDIPOST_URL!; const USER = process.env.MEDIPOST_USER!; @@ -196,25 +197,11 @@ async function saveAnalysisGroup( analysisGroup: UuringuGrupp, supabase: SupabaseClient, ) { - const { data: insertedAnalysisGroup, error } = await supabase - .schema('medreport') - .from('analysis_groups') - .upsert( - { - original_id: analysisGroup.UuringuGruppId, - name: analysisGroup.UuringuGruppNimi, - order: analysisGroup.UuringuGruppJarjekord, - }, - { onConflict: 'original_id', ignoreDuplicates: false }, - ) - .select('id'); - - if (error || !insertedAnalysisGroup[0]?.id) { - throw new Error( - `Failed to insert analysis group (id: ${analysisGroup.UuringuGruppId}), error: ${error?.message}`, - ); - } - const analysisGroupId = insertedAnalysisGroup[0].id; + const analysisGroupId = await createAnalysisGroup({ + id: analysisGroup.UuringuGruppId, + name: analysisGroup.UuringuGruppNimi, + order: analysisGroup.UuringuGruppJarjekord, + }); const analysisGroupCodes = toArray(analysisGroup.Kood); const codes: Partial>[] = diff --git a/lib/services/sync-entries.service.ts b/lib/services/sync-entries.service.ts new file mode 100644 index 0000000..01ca7bb --- /dev/null +++ b/lib/services/sync-entries.service.ts @@ -0,0 +1,18 @@ +import { format } from 'date-fns'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +export const getLastCheckedDate = async () => { + const { data: lastChecked } = await getSupabaseServerAdminClient() + .schema('audit') + .from('sync_entries') + .select('created_at') + .eq('status', 'SUCCESS') + .order('created_at') + .limit(1); + const lastEntry = lastChecked?.[0]; + const lastCheckedDate = lastEntry + ? format(lastEntry.created_at, 'yyyy-MM-dd HH:mm:ss') + : null; + + return lastCheckedDate; +} From 959646a31976f16d939612908799b829e8766f4b Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:52:14 +0300 Subject: [PATCH 07/30] feat(MED-131): direct imports --- app/home/(user)/(dashboard)/cart/page.tsx | 2 +- app/home/(user)/(dashboard)/order/page.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/page.tsx b/app/home/(user)/(dashboard)/cart/page.tsx index 7e66b9f..4e96c49 100644 --- a/app/home/(user)/(dashboard)/cart/page.tsx +++ b/app/home/(user)/(dashboard)/cart/page.tsx @@ -5,7 +5,7 @@ import { notFound } from 'next/navigation'; import { retrieveCart } from '@lib/data/cart'; import Cart from '../../_components/cart'; -import { listProductTypes } from '@lib/data'; +import { listProductTypes } from '@lib/data/products'; import CartTimer from '../../_components/cart/cart-timer'; import { Trans } from '@kit/ui/trans'; diff --git a/app/home/(user)/(dashboard)/order/page.tsx b/app/home/(user)/(dashboard)/order/page.tsx index 511e861..a26d6e1 100644 --- a/app/home/(user)/(dashboard)/order/page.tsx +++ b/app/home/(user)/(dashboard)/order/page.tsx @@ -2,7 +2,8 @@ import { redirect } from 'next/navigation'; import { listOrders } from '~/medusa/lib/data/orders'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; -import { listProductTypes, retrieveCustomer } from '@lib/data'; +import { listProductTypes } from '@lib/data/products'; +import { retrieveCustomer } from '@lib/data/customer'; import { PageBody } from '@kit/ui/makerkit/page'; import pathsConfig from '~/config/paths.config'; import { Trans } from '@kit/ui/trans'; From 12cd61840c095d19c69bdc313c0680ef8a5a5605 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:52:22 +0300 Subject: [PATCH 08/30] feat(MED-131): update location in action so cart is always reloaded properly --- .../_components/cart/analysis-location.tsx | 18 ++++----- .../server/update-cart-partner-location.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 app/home/(user)/_lib/server/update-cart-partner-location.ts diff --git a/app/home/(user)/_components/cart/analysis-location.tsx b/app/home/(user)/_components/cart/analysis-location.tsx index b6128e7..ff1ff0e 100644 --- a/app/home/(user)/_components/cart/analysis-location.tsx +++ b/app/home/(user)/_components/cart/analysis-location.tsx @@ -3,7 +3,6 @@ import { toast } from 'sonner'; import { useForm } from "react-hook-form"; import { z } from "zod"; -import { updateLineItem } from "@lib/data/cart" import { StoreCart, StoreCartLineItem } from "@medusajs/types" import { Form } from "@kit/ui/form"; import { Trans } from '@kit/ui/trans'; @@ -18,6 +17,7 @@ import { SelectTrigger, SelectValue, } from '@kit/ui/select'; +import { updateCartPartnerLocation } from '../../_lib/server/update-cart-partner-location'; const AnalysisLocationSchema = z.object({ locationId: z.string().min(1), @@ -40,16 +40,12 @@ export default function AnalysisLocation({ cart, analysisPackages }: { cart: Sto }); const onSubmit = async ({ locationId }: z.infer) => { - const promise = Promise.all(analysisPackages.map(async ({ id, quantity }) => { - await updateLineItem({ - lineId: id, - quantity, - metadata: { - partner_location_name: MOCK_LOCATIONS.find((location) => location.id === locationId)?.name ?? '', - partner_location_id: locationId, - }, - }); - })); + const promise = updateCartPartnerLocation({ + cartId: cart.id, + lineIds: analysisPackages.map(({ id }) => id), + partnerLocationId: locationId, + partnerLocationName: MOCK_LOCATIONS.find((location) => location.id === locationId)?.name ?? '', + }); toast.promise(promise, { success: t(`cart:items.analysisLocation.success`), diff --git a/app/home/(user)/_lib/server/update-cart-partner-location.ts b/app/home/(user)/_lib/server/update-cart-partner-location.ts new file mode 100644 index 0000000..0ad1c6f --- /dev/null +++ b/app/home/(user)/_lib/server/update-cart-partner-location.ts @@ -0,0 +1,38 @@ +"use server"; + +import { retrieveCart, updateCart, updateLineItem } from "@lib/data/cart"; + +export const updateCartPartnerLocation = async ({ + cartId, + lineIds, + partnerLocationId, + partnerLocationName, +}: { + cartId: string; + lineIds: string[]; + partnerLocationId: string; + partnerLocationName: string; +}) => { + const cart = await retrieveCart(cartId); + if (!cart) { + throw new Error("Cart not found"); + } + + for (const lineItemId of lineIds) { + await updateLineItem({ + lineId: lineItemId, + quantity: 1, + metadata: { + partner_location_name: partnerLocationName, + partner_location_id: partnerLocationId, + }, + }); + } + await updateCart({ + id: cartId, + metadata: { + partner_location_name: partnerLocationName, + partner_location_id: partnerLocationId, + }, + }); +} From c681063e8ddb164899c428124c0bd963cc3c528c Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:52:31 +0300 Subject: [PATCH 09/30] feat(MED-131): show separate text in case of no existing analysis results --- app/home/(user)/(dashboard)/analysis-results/page.tsx | 8 ++++++-- public/locales/en/account.json | 5 +++-- public/locales/et/account.json | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/home/(user)/(dashboard)/analysis-results/page.tsx b/app/home/(user)/(dashboard)/analysis-results/page.tsx index 23a79f8..29bdcb2 100644 --- a/app/home/(user)/(dashboard)/analysis-results/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/page.tsx @@ -28,7 +28,11 @@ async function AnalysisResultsPage() {

- + {analysisList && analysisList.length > 0 ? ( + + ) : ( + + )}

From c02cb046a56b690306cda073aa24d1659fa54b29 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:55:13 +0300 Subject: [PATCH 18/30] feat(MED-131): update readme for medipost and jobs --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index 4fba666..20a28d9 100644 --- a/README.md +++ b/README.md @@ -100,3 +100,42 @@ To access admin pages follow these steps: - [View emails](http://localhost:1080/#/) - Mail server is running on `localhost:1025` without password + +## Medipost flow + +1. Customer adds analysis to cart in **B2B** storefront +2. Customer checks out from cart and is redirected to **Montonio** +3. Customer pays and is redirected back to **B2B** `GET B2B/home/cart/montonio-callback?order-token=$JWT` + - **Medusa** order is created and cart is emptied + - email is sent to customer +4. When **Montonio** has confirmed payment, it will call **Medusa** webhook endpoint and **Medusa** will mark order payment as captured. + **Medusa** sends `POST B2B/api/order/medipost-create` with `medusaOrderId`. B2B sends order XML as private message to Medipost. + This could possibly later happen on montonio-callback URL also but currently not possible in Medusa + +In background a job will call `POST B2B/api/job/sync-analysis-results` every n minutes and sync private messages with responses from **Medipost**. + +In local dev environment, you can create a private message with analysis responses in **Medipost** system for a submitted order: +`POST B2B/api/order/medipost-test-response body={medusaOrderId:'input here'}` +After that run `POST /api/job/sync-analysis-results` and analysis results should be synced. + +In local dev environment, you can import products from B2B to Medusa with this API: + +- `POST /api/job/sync-analysis-groups-store` + - Syncs required data of `analyses`, `analysis_elements` data from **B2B** to **Medusa** and creates relevant products and categories. + If product or category already exists, then it is not recreated. Old entries are not deleted either currently. + +## Jobs + +Required headers: + +- `x-jobs-api-key` in UUID format + +Endpoints: + +- `POST /api/job/sync-analysis-groups` + - Queries **Medipost** for public messages list and takes analysis info from the latest event. + Updates `analyses` and `analysis_elements` lists in **B2B**. +- `POST /api/job/sync-analysis-results` + - Queries **Medipost** for latest private message that has a response and updates order analysis results from lab results data. +- `POST /api/job/sync-connected-online` + - TODO From 7c3aa45ec708c0ee50345446cdbfcec3ecb7ac0e Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:55:23 +0300 Subject: [PATCH 19/30] feat(MED-131): update analyses on package logic --- app/api/job/handler/sync-analysis-groups.ts | 10 +++- .../_components/compare-packages-modal.tsx | 50 ++++++++++--------- .../_lib/server/load-analysis-packages.ts | 5 +- components/select-analysis-package.tsx | 7 +-- lib/services/analysis-element.service.ts | 28 +++++++++-- lib/services/medipost.service.ts | 4 +- lib/services/medipostTest.service.ts | 4 +- .../src/lib/data/products.ts | 14 ------ utils/medusa-product.ts | 17 +++++++ 9 files changed, 88 insertions(+), 51 deletions(-) create mode 100644 utils/medusa-product.ts diff --git a/app/api/job/handler/sync-analysis-groups.ts b/app/api/job/handler/sync-analysis-groups.ts index fa4dc3e..fe76f2d 100644 --- a/app/api/job/handler/sync-analysis-groups.ts +++ b/app/api/job/handler/sync-analysis-groups.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { XMLParser } from 'fast-xml-parser'; import fs from 'fs'; -import { createAnalysisGroup } from '~/lib/services/analysis-group.service'; +import { createAnalysisGroup, getAnalysisGroups } from '~/lib/services/analysis-group.service'; import { IMedipostPublicMessageDataParsed } from '~/lib/services/medipost.types'; import { createAnalysis, createNoDataReceivedEntry, createNoNewDataReceivedEntry, createSyncFailEntry, createSyncSuccessEntry } from '~/lib/services/analyses.service'; import { getLastCheckedDate } from '~/lib/services/sync-entries.service'; @@ -64,6 +64,8 @@ export default async function syncAnalysisGroups() { ); } + const existingAnalysisGroups = await getAnalysisGroups(); + // SAVE PUBLIC MESSAGE DATA const providers = toArray(parsed?.Saadetis?.Teenused.Teostaja); @@ -79,6 +81,12 @@ export default async function syncAnalysisGroups() { const codes: ICode[] = []; for (const analysisGroup of analysisGroups) { + const existingAnalysisGroup = existingAnalysisGroups?.find(({ original_id }) => original_id === analysisGroup.UuringuGruppId); + if (existingAnalysisGroup) { + console.info(`Analysis group '${analysisGroup.UuringuGruppNimi}' already exists`); + continue; + } + // SAVE ANALYSIS GROUP const analysisGroupId = await createAnalysisGroup({ id: analysisGroup.UuringuGruppId, diff --git a/app/home/(user)/_components/compare-packages-modal.tsx b/app/home/(user)/_components/compare-packages-modal.tsx index 247c6ca..545edf4 100644 --- a/app/home/(user)/_components/compare-packages-modal.tsx +++ b/app/home/(user)/_components/compare-packages-modal.tsx @@ -24,7 +24,7 @@ import { PackageHeader } from '@/components/package-header'; import { InfoTooltip } from '@/components/ui/info-tooltip'; import { StoreProduct } from '@medusajs/types'; import type { AnalysisElement } from '~/lib/services/analysis-element.service'; -import { getAnalysisElementOriginalIds } from '@lib/data/products'; +import { getAnalysisElementOriginalIds } from '@/utils/medusa-product'; const CheckWithBackground = () => { return ( @@ -34,6 +34,24 @@ const CheckWithBackground = () => { ); }; +const PackageTableHead = async ({ product, nrOfAnalyses }: { product: StoreProduct, nrOfAnalyses: number }) => { + const { t, language } = await createI18nServerInstance(); + const variant = product.variants?.[0]; + const titleKey = product.title; + const price = variant?.calculated_price?.calculated_amount ?? 0; + return ( + + + + ) +} + const ComparePackagesModal = async ({ analysisElements, analysisPackages, @@ -43,7 +61,7 @@ const ComparePackagesModal = async ({ analysisPackages: StoreProduct[]; triggerElement: JSX.Element; }) => { - const { t, language } = await createI18nServerInstance(); + const { t } = await createI18nServerInstance(); const standardPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard')!; const standardPlusPackage = analysisPackages.find(({ metadata }) => metadata?.analysisPackageTier === 'standard-plus')!; @@ -53,9 +71,9 @@ const ComparePackagesModal = async ({ return null; } - const standardPackageAnalyses = await getAnalysisElementOriginalIds([standardPackage]); - const standardPlusPackageAnalyses = await getAnalysisElementOriginalIds([standardPlusPackage]); - const premiumPackageAnalyses = await getAnalysisElementOriginalIds([premiumPackage]); + const standardPackageAnalyses = getAnalysisElementOriginalIds([standardPackage]); + const standardPlusPackageAnalyses = getAnalysisElementOriginalIds([standardPlusPackage]); + const premiumPackageAnalyses = getAnalysisElementOriginalIds([premiumPackage]); return ( @@ -86,25 +104,9 @@ const ComparePackagesModal = async ({ - {analysisPackages.map( - (product) => { - const variant = product.variants?.[0]; - const titleKey = product.title; - const price = variant?.calculated_price?.calculated_amount ?? 0; - return ( - - - - ) - })} + + + diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts index d46e8d1..68075e7 100644 --- a/app/home/(user)/_lib/server/load-analysis-packages.ts +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -1,8 +1,9 @@ import { cache } from 'react'; -import { getAnalysisElementOriginalIds, listProductTypes, listProducts } from "@lib/data/products"; +import { listProductTypes, listProducts } from "@lib/data/products"; import { listRegions } from '@lib/data/regions'; import { AnalysisElement, getAnalysisElements } from '~/lib/services/analysis-element.service'; +import { getAnalysisElementOriginalIds } from '@/utils/medusa-product'; async function countryCodesLoader() { const countryCodes = await listRegions().then((regions) => @@ -33,7 +34,7 @@ async function analysisPackagesLoader() { }); const analysisPackages = response.products; let analysisElements: AnalysisElement[] = []; - const analysisElementOriginalIds = await getAnalysisElementOriginalIds(analysisPackages); + const analysisElementOriginalIds = getAnalysisElementOriginalIds(analysisPackages); if (analysisElementOriginalIds.length) { analysisElements = await getAnalysisElements({ originalIds: analysisElementOriginalIds }); diff --git a/components/select-analysis-package.tsx b/components/select-analysis-package.tsx index 950da9b..5dd3846 100644 --- a/components/select-analysis-package.tsx +++ b/components/select-analysis-package.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from 'react'; +import { use, useState } from 'react'; import { useTranslation } from 'react-i18next'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; @@ -19,11 +19,11 @@ import { handleAddToCart } from '@/lib/services/medusaCart.service'; import { PackageHeader } from './package-header'; import { ButtonTooltip } from './ui/button-tooltip'; +import { getAnalysisElementOriginalIds } from '@/utils/medusa-product'; export interface IAnalysisPackage { titleKey: string; price: number; - nrOfAnalyses: number | string; tagColor: string; descriptionKey: string; } @@ -52,7 +52,8 @@ export default function SelectAnalysisPackage({ } const titleKey = analysisPackage.title; - const nrOfAnalyses = analysisPackage?.metadata?.nrOfAnalyses ?? 0; + const analysisElementOriginalIds = getAnalysisElementOriginalIds([analysisPackage]); + const nrOfAnalyses = analysisElementOriginalIds.length; const description = analysisPackage.description ?? ''; const subtitle = analysisPackage.subtitle ?? ''; const variant = analysisPackage.variants?.[0]; diff --git a/lib/services/analysis-element.service.ts b/lib/services/analysis-element.service.ts index a92f27d..2777a77 100644 --- a/lib/services/analysis-element.service.ts +++ b/lib/services/analysis-element.service.ts @@ -9,10 +9,8 @@ export type AnalysisElement = Tables<{ schema: 'medreport' }, 'analysis_elements export async function getAnalysisElements({ originalIds, - ids, }: { originalIds?: string[]; - ids?: number[]; }): Promise { const query = getSupabaseServerClient() .schema('medreport') @@ -24,11 +22,35 @@ export async function getAnalysisElements({ query.in('analysis_id_original', [...new Set(originalIds)]); } + const { data: analysisElements, error } = await query; + + if (error) { + throw new Error(`Failed to get analysis elements: ${error.message}`); + } + + return analysisElements ?? []; +} + +export async function getAnalysisElementsAdmin({ + ids, +}: { + ids?: number[]; +} = {}): Promise { + const query = getSupabaseServerAdminClient() + .schema('medreport') + .from('analysis_elements') + .select(`*, analysis_groups(*)`) + .order('order', { ascending: true }); + if (Array.isArray(ids)) { query.in('id', ids); } - const { data: analysisElements } = await query; + const { data: analysisElements, error } = await query; + + if (error) { + throw new Error(`Failed to get analysis elements: ${error.message}`); + } return analysisElements ?? []; } diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 1a2dfee..1ec637c 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -37,7 +37,7 @@ import { Tables } from '@kit/supabase/database'; import { createAnalysisGroup } from './analysis-group.service'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; import { getOrder } from './order.service'; -import { getAnalysisElements } from './analysis-element.service'; +import { getAnalysisElementsAdmin } from './analysis-element.service'; import { getAnalyses } from './analyses.service'; const BASE_URL = process.env.MEDIPOST_URL!; @@ -407,7 +407,7 @@ export async function composeOrderXML({ orderCreatedAt: Date; comment?: string; }) { - const analysisElements = await getAnalysisElements({ ids: orderedAnalysisElementsIds }); + const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds }); const analyses = await getAnalyses({ ids: orderedAnalysesIds }); const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] = diff --git a/lib/services/medipostTest.service.ts b/lib/services/medipostTest.service.ts index ad2ad39..ee5a294 100644 --- a/lib/services/medipostTest.service.ts +++ b/lib/services/medipostTest.service.ts @@ -16,7 +16,7 @@ import { uniqBy } from 'lodash'; import { Tables } from '@kit/supabase/database'; import { formatDate } from 'date-fns'; import { getAnalyses } from './analyses.service'; -import { getAnalysisElements } from './analysis-element.service'; +import { getAnalysisElementsAdmin } from './analysis-element.service'; import { validateMedipostResponse } from './medipost.service'; const BASE_URL = process.env.MEDIPOST_URL!; @@ -70,7 +70,7 @@ export async function composeOrderTestResponseXML({ orderId: string; orderCreatedAt: Date; }) { - const analysisElements = await getAnalysisElements({ ids: orderedAnalysisElementsIds }); + const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds }); const analyses = await getAnalyses({ ids: orderedAnalysesIds }); const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] = diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts index 3608d2d..1997706 100644 --- a/packages/features/medusa-storefront/src/lib/data/products.ts +++ b/packages/features/medusa-storefront/src/lib/data/products.ts @@ -85,20 +85,6 @@ export const listProducts = async ({ }) } -export const getAnalysisElementOriginalIds = async (products: HttpTypes.StoreProduct[]) => { - return products - .flatMap(({ metadata }) => { - const value = metadata?.analysisElementOriginalIds; - try { - return JSON.parse(value as string); - } catch (e) { - console.error("Failed to parse analysisElementOriginalIds from analysis package, possibly invalid format", e); - return []; - } - }) - .filter(Boolean) as string[]; -} - /** * This will fetch 100 products to the Next.js cache and sort them based on the sortBy parameter. * It will then return the paginated products based on the page and limit parameters. diff --git a/utils/medusa-product.ts b/utils/medusa-product.ts new file mode 100644 index 0000000..dc9cd32 --- /dev/null +++ b/utils/medusa-product.ts @@ -0,0 +1,17 @@ +export const getAnalysisElementOriginalIds = (products: ({ metadata?: { analysisElementOriginalIds?: string } | null } | null)[]) => { + if (!products) { + return []; + } + + return products + .flatMap((product) => { + const value = product?.metadata?.analysisElementOriginalIds; + try { + return JSON.parse(value as string); + } catch (e) { + console.error("Failed to parse analysisElementOriginalIds from analysis package, possibly invalid format", e); + return []; + } + }) + .filter(Boolean) as string[]; +} From 0d1e255fe2a8efe8cad3e7f3346a96162bbea052 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 11:49:03 +0300 Subject: [PATCH 20/30] feat(MED-131): redirect to /home in case of no redirect path --- app/auth/verify/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/auth/verify/page.tsx b/app/auth/verify/page.tsx index 8d275bf..2bddfa4 100644 --- a/app/auth/verify/page.tsx +++ b/app/auth/verify/page.tsx @@ -40,7 +40,7 @@ async function VerifyPage(props: Props) { } const nextPath = (await props.searchParams).next; - const redirectPath = nextPath ?? pathsConfig.app.home; + const redirectPath = !!nextPath && nextPath.length > 0 ? nextPath : pathsConfig.app.home; return ( Date: Mon, 4 Aug 2025 12:44:36 +0300 Subject: [PATCH 21/30] feat(MED-131): analysis package has list of medusa product IDs, not medipost IDs --- .../order-analysis-package/page.tsx | 4 +-- .../_components/compare-packages-modal.tsx | 35 +++++++++---------- .../_lib/server/load-analysis-packages.ts | 29 +++++++++------ app/select-package/page.tsx | 4 +-- components/select-analysis-package.tsx | 6 ++-- .../src/lib/data/products.ts | 2 +- utils/medusa-product.ts | 10 +++--- 7 files changed, 49 insertions(+), 41 deletions(-) diff --git a/app/home/(user)/(dashboard)/order-analysis-package/page.tsx b/app/home/(user)/(dashboard)/order-analysis-package/page.tsx index 5c7db8b..8be76c5 100644 --- a/app/home/(user)/(dashboard)/order-analysis-package/page.tsx +++ b/app/home/(user)/(dashboard)/order-analysis-package/page.tsx @@ -20,7 +20,7 @@ export const generateMetadata = async () => { }; async function OrderAnalysisPackagePage() { - const { analysisElements, analysisPackages, countryCode } = await loadAnalysisPackages(); + const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages(); return ( @@ -29,8 +29,8 @@ async function OrderAnalysisPackagePage() { diff --git a/app/home/(user)/_components/compare-packages-modal.tsx b/app/home/(user)/_components/compare-packages-modal.tsx index 545edf4..1b561ad 100644 --- a/app/home/(user)/_components/compare-packages-modal.tsx +++ b/app/home/(user)/_components/compare-packages-modal.tsx @@ -23,8 +23,7 @@ import { withI18n } from '~/lib/i18n/with-i18n'; import { PackageHeader } from '@/components/package-header'; import { InfoTooltip } from '@/components/ui/info-tooltip'; import { StoreProduct } from '@medusajs/types'; -import type { AnalysisElement } from '~/lib/services/analysis-element.service'; -import { getAnalysisElementOriginalIds } from '@/utils/medusa-product'; +import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; const CheckWithBackground = () => { return ( @@ -53,12 +52,12 @@ const PackageTableHead = async ({ product, nrOfAnalyses }: { product: StoreProdu } const ComparePackagesModal = async ({ - analysisElements, analysisPackages, + analysisPackageElements, triggerElement, }: { - analysisElements: AnalysisElement[]; analysisPackages: StoreProduct[]; + analysisPackageElements: StoreProduct[]; triggerElement: JSX.Element; }) => { const { t } = await createI18nServerInstance(); @@ -71,9 +70,9 @@ const ComparePackagesModal = async ({ return null; } - const standardPackageAnalyses = getAnalysisElementOriginalIds([standardPackage]); - const standardPlusPackageAnalyses = getAnalysisElementOriginalIds([standardPlusPackage]); - const premiumPackageAnalyses = getAnalysisElementOriginalIds([premiumPackage]); + const standardPackageAnalyses = getAnalysisElementMedusaProductIds([standardPackage]); + const standardPlusPackageAnalyses = getAnalysisElementMedusaProductIds([standardPlusPackage]); + const premiumPackageAnalyses = getAnalysisElementMedusaProductIds([premiumPackage]); return ( @@ -110,28 +109,26 @@ const ComparePackagesModal = async ({ - {analysisElements.map( + {analysisPackageElements.map( ( { - analysis_name_lab: analysisName, - analysis_id_original: analysisId, + title, + id, + description, }, index, ) => { - if (!analysisName) { + if (!title) { return null; } - const includedInStandard = standardPackageAnalyses.includes(analysisId); - const includedInStandardPlus = standardPlusPackageAnalyses.includes(analysisId); - const includedInPremium = premiumPackageAnalyses.includes(analysisId); + const includedInStandard = standardPackageAnalyses.includes(id); + const includedInStandardPlus = standardPlusPackageAnalyses.includes(id); + const includedInPremium = premiumPackageAnalyses.includes(id); return ( - {analysisName}{' '} - {/* } - /> */} + {title}{' '} + {description && (} />)} {includedInStandard && } diff --git a/app/home/(user)/_lib/server/load-analysis-packages.ts b/app/home/(user)/_lib/server/load-analysis-packages.ts index 68075e7..b7caa78 100644 --- a/app/home/(user)/_lib/server/load-analysis-packages.ts +++ b/app/home/(user)/_lib/server/load-analysis-packages.ts @@ -2,8 +2,8 @@ import { cache } from 'react'; import { listProductTypes, listProducts } from "@lib/data/products"; import { listRegions } from '@lib/data/regions'; -import { AnalysisElement, getAnalysisElements } from '~/lib/services/analysis-element.service'; -import { getAnalysisElementOriginalIds } from '@/utils/medusa-product'; +import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; +import type { StoreProduct } from '@medusajs/types'; async function countryCodesLoader() { const countryCodes = await listRegions().then((regions) => @@ -23,23 +23,32 @@ async function analysisPackagesLoader() { const [countryCodes, productTypes] = await Promise.all([loadCountryCodes(), loadProductTypes()]); const countryCode = countryCodes[0]!; + let analysisPackages: StoreProduct[] = []; + let analysisPackageElements: StoreProduct[] = []; + const productType = productTypes.find(({ metadata }) => metadata?.handle === 'analysis-packages'); if (!productType) { - return { analysisElements: [], analysisPackages: [], countryCode }; + return { analysisPackageElements, analysisPackages, countryCode }; } - const { response } = await listProducts({ + const analysisPackagesResponse = await listProducts({ countryCode, queryParams: { limit: 100, "type_id[0]": productType.id }, }); - const analysisPackages = response.products; - let analysisElements: AnalysisElement[] = []; - const analysisElementOriginalIds = getAnalysisElementOriginalIds(analysisPackages); + analysisPackages = analysisPackagesResponse.response.products; - if (analysisElementOriginalIds.length) { - analysisElements = await getAnalysisElements({ originalIds: analysisElementOriginalIds }); + const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds(analysisPackages); + if (analysisElementMedusaProductIds.length > 0) { + const { response: { products } } = await listProducts({ + countryCode, + queryParams: { + id: analysisElementMedusaProductIds, + limit: 100, + }, + }); + analysisPackageElements = products; } - return { analysisElements, analysisPackages, countryCode }; + return { analysisPackageElements, analysisPackages, countryCode }; } export const loadAnalysisPackages = cache(analysisPackagesLoader); diff --git a/app/select-package/page.tsx b/app/select-package/page.tsx index 5549cc2..56742bd 100644 --- a/app/select-package/page.tsx +++ b/app/select-package/page.tsx @@ -24,7 +24,7 @@ export const generateMetadata = async () => { }; async function SelectPackagePage() { - const { analysisElements, analysisPackages, countryCode } = await loadAnalysisPackages(); + const { analysisPackageElements, analysisPackages, countryCode } = await loadAnalysisPackages(); return (
@@ -34,8 +34,8 @@ async function SelectPackagePage() { diff --git a/components/select-analysis-package.tsx b/components/select-analysis-package.tsx index 5dd3846..b4863cf 100644 --- a/components/select-analysis-package.tsx +++ b/components/select-analysis-package.tsx @@ -19,7 +19,7 @@ import { handleAddToCart } from '@/lib/services/medusaCart.service'; import { PackageHeader } from './package-header'; import { ButtonTooltip } from './ui/button-tooltip'; -import { getAnalysisElementOriginalIds } from '@/utils/medusa-product'; +import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; export interface IAnalysisPackage { titleKey: string; @@ -52,8 +52,8 @@ export default function SelectAnalysisPackage({ } const titleKey = analysisPackage.title; - const analysisElementOriginalIds = getAnalysisElementOriginalIds([analysisPackage]); - const nrOfAnalyses = analysisElementOriginalIds.length; + const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds([analysisPackage]); + const nrOfAnalyses = analysisElementMedusaProductIds.length; const description = analysisPackage.description ?? ''; const subtitle = analysisPackage.subtitle ?? ''; const variant = analysisPackage.variants?.[0]; diff --git a/packages/features/medusa-storefront/src/lib/data/products.ts b/packages/features/medusa-storefront/src/lib/data/products.ts index 1997706..d242b3c 100644 --- a/packages/features/medusa-storefront/src/lib/data/products.ts +++ b/packages/features/medusa-storefront/src/lib/data/products.ts @@ -14,7 +14,7 @@ export const listProducts = async ({ regionId, }: { pageParam?: number - queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string } + queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams & { "type_id[0]"?: string; id?: string[] } countryCode?: string regionId?: string }): Promise<{ diff --git a/utils/medusa-product.ts b/utils/medusa-product.ts index dc9cd32..a93669b 100644 --- a/utils/medusa-product.ts +++ b/utils/medusa-product.ts @@ -1,17 +1,19 @@ -export const getAnalysisElementOriginalIds = (products: ({ metadata?: { analysisElementOriginalIds?: string } | null } | null)[]) => { +export const getAnalysisElementMedusaProductIds = (products: ({ metadata?: { analysisElementMedusaProductIds?: string } | null } | null)[]) => { if (!products) { return []; } - return products + const mapped = products .flatMap((product) => { - const value = product?.metadata?.analysisElementOriginalIds; + const value = product?.metadata?.analysisElementMedusaProductIds; try { return JSON.parse(value as string); } catch (e) { - console.error("Failed to parse analysisElementOriginalIds from analysis package, possibly invalid format", e); + console.error("Failed to parse analysisElementMedusaProductIds from analysis package, possibly invalid format", e); return []; } }) .filter(Boolean) as string[]; + + return [...new Set(mapped)]; } From b2003ad30d054fb62f5be1944c5eadf8d744b2db Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 12:49:03 +0300 Subject: [PATCH 22/30] feat(MED-131): move medipost xml logic to service --- README.md | 5 +-- app/api/order/medipost-create/route.ts | 51 ++------------------------ lib/services/medipost.service.ts | 40 ++++++++++++++++++++ 3 files changed, 46 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 20a28d9..3ace0ef 100644 --- a/README.md +++ b/README.md @@ -108,9 +108,8 @@ To access admin pages follow these steps: 3. Customer pays and is redirected back to **B2B** `GET B2B/home/cart/montonio-callback?order-token=$JWT` - **Medusa** order is created and cart is emptied - email is sent to customer -4. When **Montonio** has confirmed payment, it will call **Medusa** webhook endpoint and **Medusa** will mark order payment as captured. - **Medusa** sends `POST B2B/api/order/medipost-create` with `medusaOrderId`. B2B sends order XML as private message to Medipost. - This could possibly later happen on montonio-callback URL also but currently not possible in Medusa + - B2B sends order XML as private message to Medipost. + When **Montonio** has confirmed payment, it will call **Medusa** webhook endpoint and **Medusa** will mark order payment as captured. In background a job will call `POST B2B/api/job/sync-analysis-results` every n minutes and sync private messages with responses from **Medipost**. diff --git a/app/api/order/medipost-create/route.ts b/app/api/order/medipost-create/route.ts index 28c919a..10b9ea1 100644 --- a/app/api/order/medipost-create/route.ts +++ b/app/api/order/medipost-create/route.ts @@ -1,51 +1,8 @@ -import { retrieveOrder } from "@lib/data"; import { NextRequest, NextResponse } from "next/server"; -import { getAccountAdmin } from "~/lib/services/account.service"; -import { composeOrderXML, sendPrivateMessage } from "~/lib/services/medipost.service"; -import { getOrder, updateOrder } from "~/lib/services/order.service"; - -interface MedipostCreateRequest { - medusaOrderId: string; -} +import { sendOrderToMedipost } from "~/lib/services/medipost.service"; export const POST = async (request: NextRequest) => { - const { medusaOrderId } = (await request.json()) as MedipostCreateRequest; - const medusaOrder = await retrieveOrder(medusaOrderId) - const medreportOrder = await getOrder({ medusaOrderId }); - - const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); - - const ANALYSIS_ELEMENT_HANDLE_PREFIX = 'analysis-element-'; - const orderedAnalysisElementsIds = (medusaOrder?.items ?? []) - .filter((item) => item.product?.handle?.startsWith(ANALYSIS_ELEMENT_HANDLE_PREFIX)) - .map((item) => { - const id = Number(item.product?.handle?.replace(ANALYSIS_ELEMENT_HANDLE_PREFIX, '')); - if (Number.isNaN(id)) { - return null; - } - return id; - }) - .filter(Boolean) as number[]; - const orderXml = await composeOrderXML({ - person: { - idCode: account.personal_code!, - firstName: account.name ?? '', - lastName: account.last_name ?? '', - phone: account.phone ?? '', - }, - orderedAnalysisElementsIds, - orderedAnalysesIds: [], - orderId: medusaOrderId, - orderCreatedAt: new Date(medreportOrder.created_at), - comment: '', - }); - - await sendPrivateMessage(orderXml); - - await updateOrder({ - orderId: medreportOrder.id, - orderStatus: 'PROCESSING', - }); - - return NextResponse.json({ orderXml }); + const { medusaOrderId } = (await request.json()) as { medusaOrderId: string }; + await sendOrderToMedipost({ medusaOrderId }); + return NextResponse.json({ success: true }); }; diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 1ec637c..d9955a6 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -39,6 +39,8 @@ import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/se import { getOrder } from './order.service'; import { getAnalysisElementsAdmin } from './analysis-element.service'; import { getAnalyses } from './analyses.service'; +import { retrieveOrder } from '@lib/data/orders'; +import { getAccountAdmin } from './account.service'; const BASE_URL = process.env.MEDIPOST_URL!; const USER = process.env.MEDIPOST_USER!; @@ -622,3 +624,41 @@ export async function syncPrivateMessage({ return AnalysisOrderStatus[status]; } + +export async function sendOrderToMedipost({ + medusaOrderId, +}: { + medusaOrderId: string; +}) { + const medusaOrder = await retrieveOrder(medusaOrderId) + const medreportOrder = await getOrder({ medusaOrderId }); + + const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); + + const ANALYSIS_ELEMENT_HANDLE_PREFIX = 'analysis-element-'; + const orderedAnalysisElementsIds = (medusaOrder?.items ?? []) + .filter((item) => item.product?.handle?.startsWith(ANALYSIS_ELEMENT_HANDLE_PREFIX)) + .map((item) => { + const id = Number(item.product?.handle?.replace(ANALYSIS_ELEMENT_HANDLE_PREFIX, '')); + if (Number.isNaN(id)) { + return null; + } + return id; + }) + .filter(Boolean) as number[]; + const orderXml = await composeOrderXML({ + person: { + idCode: account.personal_code!, + firstName: account.name ?? '', + lastName: account.last_name ?? '', + phone: account.phone ?? '', + }, + orderedAnalysisElementsIds, + orderedAnalysesIds: [], + orderId: medusaOrderId, + orderCreatedAt: new Date(medreportOrder.created_at), + comment: '', + }); + + await sendPrivateMessage(orderXml); +} From 58e2b8dc81398bc16b8d23e8bba25148450e07f5 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 16:24:28 +0300 Subject: [PATCH 23/30] feat(MED-131): send to medipost on montonio callback --- .../montonio-callback/[montonioId]/route.ts | 5 +- lib/services/medipost.service.ts | 55 ++++++++++++++++--- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts index 15b6348..5fd1284 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts @@ -6,6 +6,7 @@ import { listProductTypes } from "@lib/data/products"; import { placeOrder, retrieveCart } from "@lib/data/cart"; import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; import { createOrder } from '~/lib/services/order.service'; +import { sendOrderToMedipost } from '~/lib/services/medipost.service'; const emailSender = process.env.EMAIL_SENDER; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!; @@ -93,6 +94,7 @@ const handleOrderToken = async (orderToken: string) => { const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id); return { + medusaOrderId: medusaOrder.id, email: medusaOrder.email, partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '', analysisPackageName: analysisPackageOrderItem?.title ?? '', @@ -121,7 +123,7 @@ export async function GET(request: Request) { throw new Error("Order result is missing"); } - const { email, partnerLocationName, analysisPackageName } = orderResult; + const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult; const personName = account.name; if (email && analysisPackageName) { await sendEmail({ email, analysisPackageName, personName, partnerLocationName, language }); @@ -129,6 +131,7 @@ export async function GET(request: Request) { // @TODO send email for separate analyses console.error("Missing email or analysisPackageName", orderResult); } + sendOrderToMedipost({ medusaOrderId }) return Response.redirect(new URL('/home/order', baseUrl)) } catch (error) { console.error("Failed to place order", error); diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index d9955a6..3fc0af1 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -41,12 +41,19 @@ import { getAnalysisElementsAdmin } from './analysis-element.service'; import { getAnalyses } from './analyses.service'; import { retrieveOrder } from '@lib/data/orders'; import { getAccountAdmin } from './account.service'; +import { StoreProduct } from '@medusajs/types'; +import { listProducts } from '@lib/data/products'; +import { listRegions } from '@lib/data/regions'; +import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; const BASE_URL = process.env.MEDIPOST_URL!; const USER = process.env.MEDIPOST_USER!; const PASSWORD = process.env.MEDIPOST_PASSWORD!; const RECIPIENT = process.env.MEDIPOST_RECIPIENT!; +const ANALYSIS_ELEMENT_HANDLE_PREFIX = 'analysis-element-'; +const ANALYSIS_PACKAGE_HANDLE_PREFIX = 'analysis-package-'; + function parseXML(xml: string) { const parser = new XMLParser({ ignoreAttributes: false }); return parser.parse(xml); @@ -630,16 +637,46 @@ export async function sendOrderToMedipost({ }: { medusaOrderId: string; }) { - const medusaOrder = await retrieveOrder(medusaOrderId) - const medreportOrder = await getOrder({ medusaOrderId }); - + const [medusaOrder, medreportOrder, countryCodes] = await Promise.all([ + retrieveOrder(medusaOrderId), + getOrder({ medusaOrderId }), + listRegions(), + ]); + const countryCode = countryCodes[0]!.countries![0]!.iso_2!; const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); + + let analysisPackageElements: StoreProduct[] = []; + const orderedAnalysisPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX)); + const orderedAnalysisElements = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_ELEMENT_HANDLE_PREFIX)); - const ANALYSIS_ELEMENT_HANDLE_PREFIX = 'analysis-element-'; - const orderedAnalysisElementsIds = (medusaOrder?.items ?? []) - .filter((item) => item.product?.handle?.startsWith(ANALYSIS_ELEMENT_HANDLE_PREFIX)) - .map((item) => { - const id = Number(item.product?.handle?.replace(ANALYSIS_ELEMENT_HANDLE_PREFIX, '')); + if (orderedAnalysisPackages.length > 0) { + const { response: { products: analysisPackages } } = await listProducts({ + countryCode, + queryParams: { limit: 100, "type_id[0]": orderedAnalysisPackages[0]!.product_type_id! }, + }); + const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds(analysisPackages); + const { response: { products } } = await listProducts({ + countryCode, + queryParams: { + id: analysisElementMedusaProductIds, + limit: 100, + }, + }); + analysisPackageElements = products; + } + + const analysisPackageElementsIds = analysisPackageElements + .map((product) => { + const id = Number(product?.metadata?.analysisIdOriginal); + if (Number.isNaN(id)) { + return null; + } + return id; + }) + .filter(Boolean) as number[]; + const orderedAnalysisElementsIds = orderedAnalysisElements + .map((line) => { + const id = Number(line.product?.handle?.replace(ANALYSIS_ELEMENT_HANDLE_PREFIX, '')); if (Number.isNaN(id)) { return null; } @@ -653,7 +690,7 @@ export async function sendOrderToMedipost({ lastName: account.last_name ?? '', phone: account.phone ?? '', }, - orderedAnalysisElementsIds, + orderedAnalysisElementsIds: [...analysisPackageElementsIds, ...orderedAnalysisElementsIds], orderedAnalysesIds: [], orderId: medusaOrderId, orderCreatedAt: new Date(medreportOrder.created_at), From 36816cfcd5d3b1ec891917cfcb26b7c83c38bcd4 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 16:26:26 +0300 Subject: [PATCH 24/30] feat(MED-131): improve logging, error validation --- app/api/job/handler/sync-analysis-results.ts | 10 ++++++++-- .../montonio-callback/[montonioId]/route.ts | 1 + lib/services/medipost.service.ts | 20 ++++++++++++++++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/api/job/handler/sync-analysis-results.ts b/app/api/job/handler/sync-analysis-results.ts index 4d78ade..ddf07de 100644 --- a/app/api/job/handler/sync-analysis-results.ts +++ b/app/api/job/handler/sync-analysis-results.ts @@ -3,10 +3,15 @@ import { readPrivateMessageResponse } from "~/lib/services/medipost.service"; export default async function syncAnalysisResults() { console.info("Syncing analysis results"); + let processedMessageIds: string[] = []; const excludedMessageIds: string[] = []; while (true) { console.info("Fetching private messages"); - const { messageIdErrored } = await readPrivateMessageResponse({ excludedMessageIds }); + const { messageIdErrored, messageIdProcessed } = await readPrivateMessageResponse({ excludedMessageIds }); + if (messageIdProcessed) { + processedMessageIds.push(messageIdProcessed); + } + if (!messageIdErrored) { console.info("No more messages to process"); break; @@ -17,7 +22,8 @@ export default async function syncAnalysisResults() { break; } - console.info(`Message id=${messageIdErrored} has no response yet, skipping`); excludedMessageIds.push(messageIdErrored); } + + console.info(`Processed ${processedMessageIds.length} messages, ids: ${processedMessageIds.join(', ')}`); } diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts index 5fd1284..8ed9d33 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts @@ -100,6 +100,7 @@ const handleOrderToken = async (orderToken: string) => { analysisPackageName: analysisPackageOrderItem?.title ?? '', }; } catch (error) { + console.error("Failed to place order", error); throw new Error(`Failed to place order, message=${error}`); } } diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 3fc0af1..694b0bf 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -195,6 +195,7 @@ export async function readPrivateMessageResponse({ excludedMessageIds: string[]; }) { let messageIdErrored: string | null = null; + let messageIdProcessed: string | null = null; try { const privateMessage = await getLatestPrivateMessageListItem({ excludedMessageIds }); if (!privateMessage) { @@ -202,7 +203,10 @@ export async function readPrivateMessageResponse({ } messageIdErrored = privateMessage.messageId; - + if (!messageIdErrored) { + throw new Error(`No message id found`); + } + const privateMessageContent = await getPrivateMessage( privateMessage.messageId, ); @@ -216,12 +220,13 @@ export async function readPrivateMessageResponse({ if (status === 'COMPLETED') { await deletePrivateMessage(privateMessage.messageId); + messageIdProcessed = privateMessage.messageId; } } catch (e) { - console.error(e); + console.warn(`Failed to process private message id=${messageIdErrored}, message=${(e as Error).message}`); } - return { messageIdErrored }; + return { messageIdErrored, messageIdProcessed }; } async function saveAnalysisGroup( @@ -417,7 +422,14 @@ export async function composeOrderXML({ comment?: string; }) { const analysisElements = await getAnalysisElementsAdmin({ ids: orderedAnalysisElementsIds }); + if (analysisElements.length !== orderedAnalysisElementsIds.length) { + throw new Error(`Got ${analysisElements.length} analysis elements, expected ${orderedAnalysisElementsIds.length}`); + } + const analyses = await getAnalyses({ ids: orderedAnalysesIds }); + if (analyses.length !== orderedAnalysesIds.length) { + throw new Error(`Got ${analyses.length} analyses, expected ${orderedAnalysesIds.length}`); + } const analysisGroups: Tables<{ schema: 'medreport' }, 'analysis_groups'>[] = uniqBy( @@ -572,6 +584,7 @@ export async function syncPrivateMessage({ ); } const analysisGroups = toArray(messageResponse.UuringuGrupp); + console.info(`Order has results for ${analysisGroups.length} analysis groups`); const responses: Omit< Tables<{ schema: 'medreport' }, 'analysis_response_elements'>, @@ -581,6 +594,7 @@ export async function syncPrivateMessage({ const groupItems = toArray( analysisGroup.Uuring as ResponseUuringuGrupp['Uuring'], ); + console.info(`Order has results in group ${analysisGroup.UuringuGruppNimi} for ${groupItems.length} analysis elements`); for (const item of groupItems) { const element = item.UuringuElement; const elementAnalysisResponses = toArray(element.UuringuVastus); From 8790b515d539d5d89a6eced9e7de9002ef082de1 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 16:27:54 +0300 Subject: [PATCH 25/30] feat(MED-131): fix medusa vs medipost vs b2b product ids mixed --- app/api/order/medipost-test-response/route.ts | 17 +-- .../montonio-callback/[montonioId]/route.ts | 16 ++- lib/services/medipost.service.ts | 118 +++++++++++------- lib/services/order.service.ts | 8 +- 4 files changed, 88 insertions(+), 71 deletions(-) diff --git a/app/api/order/medipost-test-response/route.ts b/app/api/order/medipost-test-response/route.ts index b6bf84d..c91a0a9 100644 --- a/app/api/order/medipost-test-response/route.ts +++ b/app/api/order/medipost-test-response/route.ts @@ -3,6 +3,7 @@ import { getOrder } from "~/lib/services/order.service"; import { composeOrderTestResponseXML, sendPrivateMessageTestResponse } from "~/lib/services/medipostTest.service"; import { retrieveOrder } from "@lib/data"; import { getAccountAdmin } from "~/lib/services/account.service"; +import { getOrderedAnalysisElementsIds } from "~/lib/services/medipost.service"; export async function POST(request: Request) { const isDev = process.env.NODE_ENV === 'development'; @@ -16,19 +17,9 @@ export async function POST(request: Request) { const medreportOrder = await getOrder({ medusaOrderId }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); + const orderedAnalysisElementsIds = await getOrderedAnalysisElementsIds({ medusaOrder }); - const ANALYSIS_ELEMENT_HANDLE_PREFIX = 'analysis-element-'; - const orderedAnalysisElementsIds = (medusaOrder?.items ?? []) - .filter((item) => item.product?.handle?.startsWith(ANALYSIS_ELEMENT_HANDLE_PREFIX)) - .map((item) => { - const id = Number(item.product?.handle?.replace(ANALYSIS_ELEMENT_HANDLE_PREFIX, '')); - if (Number.isNaN(id)) { - return null; - } - return id; - }) - .filter(Boolean) as number[]; - + console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`); const messageXml = await composeOrderTestResponseXML({ person: { idCode: account.personal_code!, @@ -36,7 +27,7 @@ export async function POST(request: Request) { lastName: account.last_name ?? '', phone: account.phone ?? '', }, - orderedAnalysisElementsIds, + orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId), orderedAnalysesIds: [], orderId: medusaOrderId, orderCreatedAt: new Date(medreportOrder.created_at), diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts index 8ed9d33..6bddbf5 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/[montonioId]/route.ts @@ -6,7 +6,7 @@ import { listProductTypes } from "@lib/data/products"; import { placeOrder, retrieveCart } from "@lib/data/cart"; import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; import { createOrder } from '~/lib/services/order.service'; -import { sendOrderToMedipost } from '~/lib/services/medipost.service'; +import { getOrderedAnalysisElementsIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; const emailSender = process.env.EMAIL_SENDER; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL!; @@ -88,7 +88,8 @@ const handleOrderToken = async (orderToken: string) => { } const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: true }); - await createOrder({ medusaOrder: medusaOrder }); + const orderedAnalysisElements = await getOrderedAnalysisElementsIds({ medusaOrder }); + await createOrder({ medusaOrder, orderedAnalysisElements }); const { productTypes } = await listProductTypes(); const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); @@ -98,6 +99,7 @@ const handleOrderToken = async (orderToken: string) => { email: medusaOrder.email, partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '', analysisPackageName: analysisPackageOrderItem?.title ?? '', + orderedAnalysisElements, }; } catch (error) { console.error("Failed to place order", error); @@ -124,15 +126,19 @@ export async function GET(request: Request) { throw new Error("Order result is missing"); } - const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult; + const { medusaOrderId, email, partnerLocationName, analysisPackageName, orderedAnalysisElements } = orderResult; const personName = account.name; if (email && analysisPackageName) { - await sendEmail({ email, analysisPackageName, personName, partnerLocationName, language }); + try { + await sendEmail({ email, analysisPackageName, personName, partnerLocationName, language }); + } catch (error) { + console.error("Failed to send email", error); + } } else { // @TODO send email for separate analyses console.error("Missing email or analysisPackageName", orderResult); } - sendOrderToMedipost({ medusaOrderId }) + sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }) return Response.redirect(new URL('/home/order', baseUrl)) } catch (error) { console.error("Failed to place order", error); diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 694b0bf..39a7d66 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -37,11 +37,10 @@ import { Tables } from '@kit/supabase/database'; import { createAnalysisGroup } from './analysis-group.service'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; import { getOrder } from './order.service'; -import { getAnalysisElementsAdmin } from './analysis-element.service'; +import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service'; import { getAnalyses } from './analyses.service'; -import { retrieveOrder } from '@lib/data/orders'; import { getAccountAdmin } from './account.service'; -import { StoreProduct } from '@medusajs/types'; +import { StoreOrder } from '@medusajs/types'; import { listProducts } from '@lib/data/products'; import { listRegions } from '@lib/data/regions'; import { getAnalysisElementMedusaProductIds } from '@/utils/medusa-product'; @@ -648,55 +647,14 @@ export async function syncPrivateMessage({ export async function sendOrderToMedipost({ medusaOrderId, + orderedAnalysisElements, }: { medusaOrderId: string; + orderedAnalysisElements: { analysisElementId: number }[]; }) { - const [medusaOrder, medreportOrder, countryCodes] = await Promise.all([ - retrieveOrder(medusaOrderId), - getOrder({ medusaOrderId }), - listRegions(), - ]); - const countryCode = countryCodes[0]!.countries![0]!.iso_2!; + const medreportOrder = await getOrder({ medusaOrderId }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); - - let analysisPackageElements: StoreProduct[] = []; - const orderedAnalysisPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX)); - const orderedAnalysisElements = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_ELEMENT_HANDLE_PREFIX)); - if (orderedAnalysisPackages.length > 0) { - const { response: { products: analysisPackages } } = await listProducts({ - countryCode, - queryParams: { limit: 100, "type_id[0]": orderedAnalysisPackages[0]!.product_type_id! }, - }); - const analysisElementMedusaProductIds = getAnalysisElementMedusaProductIds(analysisPackages); - const { response: { products } } = await listProducts({ - countryCode, - queryParams: { - id: analysisElementMedusaProductIds, - limit: 100, - }, - }); - analysisPackageElements = products; - } - - const analysisPackageElementsIds = analysisPackageElements - .map((product) => { - const id = Number(product?.metadata?.analysisIdOriginal); - if (Number.isNaN(id)) { - return null; - } - return id; - }) - .filter(Boolean) as number[]; - const orderedAnalysisElementsIds = orderedAnalysisElements - .map((line) => { - const id = Number(line.product?.handle?.replace(ANALYSIS_ELEMENT_HANDLE_PREFIX, '')); - if (Number.isNaN(id)) { - return null; - } - return id; - }) - .filter(Boolean) as number[]; const orderXml = await composeOrderXML({ person: { idCode: account.personal_code!, @@ -704,7 +662,7 @@ export async function sendOrderToMedipost({ lastName: account.last_name ?? '', phone: account.phone ?? '', }, - orderedAnalysisElementsIds: [...analysisPackageElementsIds, ...orderedAnalysisElementsIds], + orderedAnalysisElementsIds: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId), orderedAnalysesIds: [], orderId: medusaOrderId, orderCreatedAt: new Date(medreportOrder.created_at), @@ -713,3 +671,67 @@ export async function sendOrderToMedipost({ await sendPrivateMessage(orderXml); } + +export async function getOrderedAnalysisElementsIds({ + medusaOrder, +}: { + medusaOrder: StoreOrder; +}): Promise<{ + analysisElementId: number; +}[]> { + const countryCodes = await listRegions(); + const countryCode = countryCodes[0]!.countries![0]!.iso_2!; + + function getOrderedAnalysisElements(medusaOrder: StoreOrder) { + return (medusaOrder?.items ?? []) + .filter(({ product }) => product?.handle.startsWith(ANALYSIS_ELEMENT_HANDLE_PREFIX)) + .map((line) => { + const analysisElementId = Number(line.product?.handle?.replace(ANALYSIS_ELEMENT_HANDLE_PREFIX, '')); + if (Number.isNaN(analysisElementId)) { + return null; + } + return { analysisElementId }; + }) as { analysisElementId: number }[]; + } + + async function getOrderedAnalysisPackages(medusaOrder: StoreOrder) { + const orderedPackages = (medusaOrder?.items ?? []).filter(({ product }) => product?.handle.startsWith(ANALYSIS_PACKAGE_HANDLE_PREFIX)); + const orderedPackageIds = orderedPackages.map(({ product }) => product?.id).filter(Boolean) as string[]; + if (orderedPackageIds.length === 0) { + return []; + } + console.info(`Order has ${orderedPackageIds.length} packages`); + const { response: { products: orderedPackagesProducts } } = await listProducts({ + countryCode, + queryParams: { limit: 100, id: orderedPackageIds }, + }); + console.info(`Order has ${orderedPackagesProducts.length} packages`); + if (orderedPackagesProducts.length !== orderedPackageIds.length) { + throw new Error(`Got ${orderedPackagesProducts.length} ordered packages products, expected ${orderedPackageIds.length}`); + } + + const ids = getAnalysisElementMedusaProductIds(orderedPackagesProducts); + const { response: { products: analysisPackagesProducts } } = await listProducts({ + countryCode, + queryParams: { limit: 100, id: ids }, + }); + if (analysisPackagesProducts.length !== ids.length) { + throw new Error(`Got ${analysisPackagesProducts.length} analysis packages products, expected ${ids.length}`); + } + + const originalIds = analysisPackagesProducts + .map(({ metadata }) => metadata?.analysisIdOriginal) + .filter((id) => typeof id === 'string'); + if (originalIds.length !== ids.length) { + throw new Error(`Got ${originalIds.length} analysis packages products with analysisIdOriginal, expected ${ids.length}`); + } + const analysisElements = await getAnalysisElements({ originalIds }); + + return analysisElements.map(({ id }) => ({ analysisElementId: id })); + } + + const analysisPackageElements = await getOrderedAnalysisPackages(medusaOrder); + const orderedAnalysisElements = getOrderedAnalysisElements(medusaOrder); + + return [...analysisPackageElements, ...orderedAnalysisElements]; +} diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index 309d702..7702d1b 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -5,15 +5,13 @@ import type { StoreOrder } from '@medusajs/types'; export async function createOrder({ medusaOrder, + orderedAnalysisElements, }: { medusaOrder: StoreOrder; + orderedAnalysisElements: { analysisElementId: number }[]; }) { const supabase = getSupabaseServerClient(); - const analysisElementIds = medusaOrder.items - ?.filter(({ product }) => product?.handle?.startsWith('analysis-element-')) - .map(({ product }) => Number(product?.handle.replace('analysis-element-', ''))) - .filter((id) => !Number.isNaN(id)) as number[]; const { data: { user } } = await supabase.auth.getUser(); if (!user) { throw new Error('User not found'); @@ -21,7 +19,7 @@ export async function createOrder({ const orderResult = await supabase.schema('medreport') .from('analysis_orders') .insert({ - analysis_element_ids: analysisElementIds, + analysis_element_ids: orderedAnalysisElements.map(({ analysisElementId }) => analysisElementId), analysis_ids: [], status: 'QUEUED', user_id: user.id, From 30c7d192fad248cb6b811e13a6dcd6a214080d68 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 16:28:24 +0300 Subject: [PATCH 26/30] feat(MED-131): fix validation --- lib/services/medipost.service.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 39a7d66..150f8dc 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -58,9 +58,18 @@ function parseXML(xml: string) { return parser.parse(xml); } -export async function validateMedipostResponse(response: string) { +export async function validateMedipostResponse(response: string, { canHaveEmptyCode = false }: { canHaveEmptyCode?: boolean } = {}) { const parsed: IMedipostResponseXMLBase = parseXML(response); - if (typeof parsed.ANSWER?.CODE !== 'number' || parsed.ANSWER?.CODE !== 0) { + const code = parsed.ANSWER?.CODE; + if (canHaveEmptyCode) { + if (code && code !== 0) { + console.error("Bad response", response); + throw new Error(`Medipost response is invalid`); + } + return; + } + + if (typeof code !== 'number' || (code !== 0 && !canHaveEmptyCode)) { console.error("Bad response", response); throw new Error(`Medipost response is invalid`); } @@ -168,7 +177,7 @@ export async function getPrivateMessage(messageId: string) { }, }); - await validateMedipostResponse(data); + await validateMedipostResponse(data, { canHaveEmptyCode: true }); return parseXML(data) as MedipostOrderResponse; } From 0a4be89fc961dfd4ca0b14483d76a93a8b93b51f Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 16:28:57 +0300 Subject: [PATCH 27/30] feat(MED-131): initial fix for analysis results table so result elements are shown correctly --- .../(dashboard)/analysis-results/page.tsx | 33 +++++++++++-------- packages/features/accounts/src/server/api.ts | 7 +--- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/home/(user)/(dashboard)/analysis-results/page.tsx b/app/home/(user)/(dashboard)/analysis-results/page.tsx index 29bdcb2..0d3687c 100644 --- a/app/home/(user)/(dashboard)/analysis-results/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/page.tsx @@ -1,3 +1,4 @@ +import { Fragment } from 'react'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { withI18n } from '@/lib/i18n/with-i18n'; @@ -40,20 +41,24 @@ async function AnalysisResultsPage() {
- {analysisList?.map((analysis, index) => ( - + {analysisList?.map((analysis) => ( + + {analysis.elements.map((element) => ( + + ))} + ))}
diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts index dba85a8..436c2e4 100644 --- a/packages/features/accounts/src/server/api.ts +++ b/packages/features/accounts/src/server/api.ts @@ -209,15 +209,10 @@ class AccountsApi { return null; } - const elementMap = new Map( - analysisResponseElements.map((e) => [e.analysis_response_id, e]), - ); - return analysisResponses - .filter((r) => elementMap.has(r.id)) .map((r) => ({ ...r, - element: elementMap.get(r.id)!, + elements: analysisResponseElements.filter((e) => e.analysis_response_id === r.id), })); } } From 84b629ab0b1064a370c3c311ec25c9bec367cd02 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 16:29:50 +0300 Subject: [PATCH 28/30] feat(MED-131): remove private messages with unknown order ID from queue --- lib/services/medipost.service.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 150f8dc..7c1e049 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -221,10 +221,19 @@ export async function readPrivateMessageResponse({ const messageResponse = privateMessageContent?.Saadetis?.Vastus; if (!messageResponse) { - throw new Error(`Invalid data in private message response`); + throw new Error(`Private message response has no results yet`); + } + console.info(`Private message content: ${JSON.stringify(privateMessageContent)}`); + + let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; + try { + order = await getOrder({ medusaOrderId: messageResponse.ValisTellimuseId }); + } catch (e) { + await deletePrivateMessage(privateMessage.messageId); + throw new Error(`Order not found by Medipost message ValisTellimuseId=${messageResponse.ValisTellimuseId}`); } - const status = await syncPrivateMessage({ messageResponse }); + const status = await syncPrivateMessage({ messageResponse, order }); if (status === 'COMPLETED') { await deletePrivateMessage(privateMessage.messageId); @@ -552,14 +561,13 @@ function getLatestMessage({ export async function syncPrivateMessage({ messageResponse, + order, }: { messageResponse: MedipostOrderResponse['Saadetis']['Vastus']; + order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; }) { const supabase = getSupabaseServerAdminClient() - const status = messageResponse.TellimuseOlek; - const order = await getOrder({ medusaOrderId: messageResponse.ValisTellimuseId }); - const { data: analysisOrder, error: analysisOrderError } = await supabase .schema('medreport') .from('analysis_orders') @@ -579,7 +587,7 @@ export async function syncPrivateMessage({ { analysis_order_id: order.id, order_number: messageResponse.TellimuseNumber, - order_status: AnalysisOrderStatus[status], + order_status: AnalysisOrderStatus[messageResponse.TellimuseOlek], user_id: analysisOrder[0].user_id, }, { onConflict: 'order_number', ignoreDuplicates: false }, @@ -651,7 +659,7 @@ export async function syncPrivateMessage({ ); } - return AnalysisOrderStatus[status]; + return AnalysisOrderStatus[messageResponse.TellimuseOlek]; } export async function sendOrderToMedipost({ From 0c6bda607dcb2c8798a0dac0a8e4008bf88c2693 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 16:30:17 +0300 Subject: [PATCH 29/30] feat(MED-131): support multiple elements under same group for creating fake medipost responses --- lib/services/medipostTest.service.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/services/medipostTest.service.ts b/lib/services/medipostTest.service.ts index ee5a294..192db4c 100644 --- a/lib/services/medipostTest.service.ts +++ b/lib/services/medipostTest.service.ts @@ -91,6 +91,12 @@ export async function composeOrderTestResponseXML({ // 5 – Tagasi lükatud, 6 – Tühistatud. const orderStatus = 4; const orderNumber = 'TSU000001200'; + + const allAnalysisElementsForGroups = analysisElements?.filter((element) => { + return analysisGroups.some((group) => group.id === element.analysis_groups.id); + }); + const addedIds = new Set(); + return ` ${getPais(USER, RECIPIENT, orderCreatedAt, orderId, "AL")} @@ -118,12 +124,17 @@ export async function composeOrderTestResponseXML({ ${orderNumber} ${orderStatus} - ${analysisGroups.map((group) => { + ${allAnalysisElementsForGroups.map((analysisElement) => { + const group = analysisGroups.find((group) => group.id === analysisElement.analysis_groups.id); + if (!group) { + throw new Error(`Failed to find group for analysis element ${analysisElement.id}`); + } + let relatedAnalysisElement = analysisElements?.find( - (element) => element.analysis_groups.id === group.id, + (element) => element.analysis_groups.id === group.id && !addedIds.has(element.id), ); const relatedAnalyses = analyses?.filter((analysis) => { - return analysis.analysis_elements.analysis_groups.id === group.id; + return analysis.analysis_elements.analysis_groups.id === group.id && !addedIds.has(analysis.analysis_elements.id); }); if (!relatedAnalysisElement) { @@ -143,6 +154,7 @@ export async function composeOrderTestResponseXML({ const lower = getRandomInt(0, 100); const upper = getRandomInt(lower + 1, 500); const result = getRandomInt(lower, upper); + addedIds.add(relatedAnalysisElement.id); return (` ${group.original_id} From e8dae56d7e86bac188a74162eca4d02423c13f29 Mon Sep 17 00:00:00 2001 From: k4rli Date: Mon, 4 Aug 2025 16:30:42 +0300 Subject: [PATCH 30/30] feat(MED-131): update type --- packages/features/accounts/src/types/accounts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/features/accounts/src/types/accounts.ts b/packages/features/accounts/src/types/accounts.ts index 459b53d..8048dea 100644 --- a/packages/features/accounts/src/types/accounts.ts +++ b/packages/features/accounts/src/types/accounts.ts @@ -2,5 +2,5 @@ import { Database } from '@kit/supabase/database'; export type UserAnalysis = (Database['medreport']['Tables']['analysis_responses']['Row'] & { - element: Database['medreport']['Tables']['analysis_response_elements']['Row']; + elements: Database['medreport']['Tables']['analysis_response_elements']['Row'][]; })[];