From ca13e9e30ac774896421bc2bc533e5e056930379 Mon Sep 17 00:00:00 2001 From: Helena <37183360+helenarebane@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:37:22 +0300 Subject: [PATCH 1/9] MED-82: add patient notification emails (#74) * MED-82: add patient notification emails * remove console.log * clean up * remove extra paragraph from email --- app/api/job/send-open-jobs-emails/route.ts | 4 +- .../analysis-results/[id]/page.tsx | 4 +- lib/services/account.service.ts | 14 +- .../audit/notificationEntries.service.ts | 9 +- lib/services/audit/pageView.service.ts | 1 - lib/services/mailer.service.ts | 2 +- .../database-webhook-router.service.ts | 68 +---- .../src/emails/account-delete.email.tsx | 11 +- .../src/emails/all-results-received.email.tsx | 10 +- .../src/emails/company-offer.email.tsx | 10 +- .../emails/doctor-summary-received.email.tsx | 45 ++- .../emails/first-results-received.email.tsx | 7 +- .../src/emails/invite.email.tsx | 15 +- .../src/emails/new-jobs-available.email.tsx | 9 +- .../src/emails/order-processing.email.tsx | 90 ++++++ .../email-templates/src/emails/otp.email.tsx | 11 +- .../patient-first-results-received.email.tsx | 81 ++++++ .../patient-full-results-received.email.tsx | 82 ++++++ .../src/emails/synlab.email.tsx | 11 +- packages/email-templates/src/index.ts | 3 + .../en/doctor-summary-received-email.json | 14 +- .../locales/en/order-processing-email.json | 13 + .../patient-first-results-received-email.json | 8 + .../patient-full-results-received-email.json | 7 + .../src/locales/et/common.json | 4 +- .../et/doctor-summary-received-email.json | 12 +- .../locales/et/order-processing-email.json | 13 + .../patient-first-results-received-email.json | 8 + .../patient-full-results-received-email.json | 7 + .../ru/doctor-summary-received-email.json | 12 +- .../locales/ru/order-processing-email.json | 13 + .../patient-first-results-received-email.json | 8 + .../patient-full-results-received-email.json | 7 + .../server/actions/doctor-server-actions.ts | 4 +- .../server/schema/doctor-analysis.schema.ts | 4 +- packages/features/notifications/package.json | 3 +- .../analysis-order-notifications.service.ts | 273 ++++++++++++++++++ 37 files changed, 718 insertions(+), 179 deletions(-) create mode 100644 packages/email-templates/src/emails/order-processing.email.tsx create mode 100644 packages/email-templates/src/emails/patient-first-results-received.email.tsx create mode 100644 packages/email-templates/src/emails/patient-full-results-received.email.tsx create mode 100644 packages/email-templates/src/locales/en/order-processing-email.json create mode 100644 packages/email-templates/src/locales/en/patient-first-results-received-email.json create mode 100644 packages/email-templates/src/locales/en/patient-full-results-received-email.json create mode 100644 packages/email-templates/src/locales/et/order-processing-email.json create mode 100644 packages/email-templates/src/locales/et/patient-first-results-received-email.json create mode 100644 packages/email-templates/src/locales/et/patient-full-results-received-email.json create mode 100644 packages/email-templates/src/locales/ru/order-processing-email.json create mode 100644 packages/email-templates/src/locales/ru/patient-first-results-received-email.json create mode 100644 packages/email-templates/src/locales/ru/patient-full-results-received-email.json create mode 100644 packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts diff --git a/app/api/job/send-open-jobs-emails/route.ts b/app/api/job/send-open-jobs-emails/route.ts index c2083bf..939f3b7 100644 --- a/app/api/job/send-open-jobs-emails/route.ts +++ b/app/api/job/send-open-jobs-emails/route.ts @@ -23,7 +23,7 @@ export const POST = async (request: NextRequest) => { 'Successfully sent out open job notification emails to doctors.', ); await createNotificationLog({ - action: NotificationAction.NEW_JOBS_ALERT, + action: NotificationAction.DOCTOR_NEW_JOBS, status: 'SUCCESS', }); return NextResponse.json( @@ -39,7 +39,7 @@ export const POST = async (request: NextRequest) => { e, ); await createNotificationLog({ - action: NotificationAction.NEW_JOBS_ALERT, + action: NotificationAction.DOCTOR_NEW_JOBS, status: 'FAIL', comment: e?.message, }); diff --git a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx index a568eed..9ce91e8 100644 --- a/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx +++ b/app/home/(user)/(dashboard)/analysis-results/[id]/page.tsx @@ -24,9 +24,9 @@ export default async function AnalysisResultsPage({ }) { const account = await loadCurrentUserAccount(); - const { id: analysisResponseId } = await params; + const { id: analysisOrderId } = await params; - const analysisResponse = await loadUserAnalysis(Number(analysisResponseId)); + const analysisResponse = await loadUserAnalysis(Number(analysisOrderId)); if (!account?.id || !analysisResponse) { return null; diff --git a/lib/services/account.service.ts b/lib/services/account.service.ts index f958c72..87eac94 100644 --- a/lib/services/account.service.ts +++ b/lib/services/account.service.ts @@ -1,6 +1,5 @@ import type { Tables } from '@/packages/supabase/src/database.types'; -import { AccountWithParams } from '@kit/accounts/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; @@ -26,6 +25,19 @@ export async function getAccount(id: string): Promise { return data as unknown as AccountWithMemberships; } +export async function getUserContactAdmin(userId: string) { + const { data } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('accounts') + .select('name, last_name, email, preferred_locale') + .eq('primary_owner_user_id', userId) + .eq('is_personal_account', true) + .single() + .throwOnError(); + + return data; +} + export async function getAccountAdmin({ primaryOwnerUserId, }: { diff --git a/lib/services/audit/notificationEntries.service.ts b/lib/services/audit/notificationEntries.service.ts index f83a736..980de2e 100644 --- a/lib/services/audit/notificationEntries.service.ts +++ b/lib/services/audit/notificationEntries.service.ts @@ -2,9 +2,12 @@ import { Database } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; export enum NotificationAction { - DOCTOR_FEEDBACK_RECEIVED = 'DOCTOR_FEEDBACK_RECEIVED', - NEW_JOBS_ALERT = 'NEW_JOBS_ALERT', - PATIENT_RESULTS_RECEIVED_ALERT = 'PATIENT_RESULTS_RECEIVED_ALERT', + DOCTOR_NEW_JOBS = 'DOCTOR_NEW_JOBS', + DOCTOR_PATIENT_RESULTS_RECEIVED = 'DOCTOR_PATIENT_RESULTS_RECEIVED', + PATIENT_DOCTOR_FEEDBACK_RECEIVED = 'PATIENT_DOCTOR_FEEDBACK_RECEIVED', + PATIENT_ORDER_PROCESSING = 'PATIENT_ORDER_PROCESSING', + PATIENT_FIRST_RESULTS_RECEIVED = 'PATIENT_FIRST_RESULTS_RECEIVED', + PATIENT_FULL_RESULTS_RECEIVED = 'PATIENT_FULL_RESULTS_RECEIVED', } export const createNotificationLog = async ({ diff --git a/lib/services/audit/pageView.service.ts b/lib/services/audit/pageView.service.ts index efac5db..aa06aec 100644 --- a/lib/services/audit/pageView.service.ts +++ b/lib/services/audit/pageView.service.ts @@ -37,7 +37,6 @@ export const createPageViewLog = async ({ account_id: accountId, action, changed_by: user.id, - extra_data: extraData, }) .throwOnError(); } catch (error) { diff --git a/lib/services/mailer.service.ts b/lib/services/mailer.service.ts index b4a7ecc..8e2a7ea 100644 --- a/lib/services/mailer.service.ts +++ b/lib/services/mailer.service.ts @@ -13,7 +13,7 @@ type EmailTemplate = { subject: string; }; -type EmailRenderer = (params: T) => Promise; +export type EmailRenderer = (params: T) => Promise; export const sendEmailFromTemplate = async ( renderer: EmailRenderer, diff --git a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts index 5abe3de..9299ae1 100644 --- a/packages/database-webhooks/src/server/services/database-webhook-router.service.ts +++ b/packages/database-webhooks/src/server/services/database-webhook-router.service.ts @@ -1,20 +1,7 @@ import { SupabaseClient } from '@supabase/supabase-js'; -import { - renderAllResultsReceivedEmail, - renderFirstResultsReceivedEmail, -} from '@kit/email-templates'; import { Database } from '@kit/supabase/database'; -import { - getAssignedDoctorAccount, - getDoctorAccounts, -} from '../../../../../lib/services/account.service'; -import { - NotificationAction, - createNotificationLog, -} from '../../../../../lib/services/audit/notificationEntries.service'; -import { sendEmailFromTemplate } from '../../../../../lib/services/mailer.service'; import { RecordChange, Tables } from '../record-change.type'; export function createDatabaseWebhookRouterService( @@ -113,58 +100,13 @@ class DatabaseWebhookRouterService { return; } - let action; - try { - const data = { - analysisOrderId: record.id, - language: 'et', - }; + const { createAnalysisOrderWebhooksService } = await import( + '@kit/notifications/webhooks/analysis-order-notifications.service' + ); - if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') { - action = NotificationAction.NEW_JOBS_ALERT; + const service = createAnalysisOrderWebhooksService(); - const doctorAccounts = await getDoctorAccounts(); - const doctorEmails: string[] = doctorAccounts - .map(({ email }) => email) - .filter((email): email is string => !!email); - - await sendEmailFromTemplate( - renderFirstResultsReceivedEmail, - data, - doctorEmails, - ); - } else if (record.status === 'FULL_ANALYSIS_RESPONSE') { - action = NotificationAction.PATIENT_RESULTS_RECEIVED_ALERT; - const doctorAccount = await getAssignedDoctorAccount(record.id); - const assignedDoctorEmail = doctorAccount?.email; - - if (!assignedDoctorEmail) { - return; - } - - await sendEmailFromTemplate( - renderAllResultsReceivedEmail, - data, - assignedDoctorEmail, - ); - } - - if (action) { - await createNotificationLog({ - action, - status: 'SUCCESS', - relatedRecordId: record.id, - }); - } - } catch (e: any) { - if (action) - await createNotificationLog({ - action, - status: 'FAIL', - comment: e?.message, - relatedRecordId: record.id, - }); - } + return service.handleStatusChangeWebhook(record); } } } diff --git a/packages/email-templates/src/emails/account-delete.email.tsx b/packages/email-templates/src/emails/account-delete.email.tsx index 78fea85..a98c682 100644 --- a/packages/email-templates/src/emails/account-delete.email.tsx +++ b/packages/email-templates/src/emails/account-delete.email.tsx @@ -49,33 +49,28 @@ export async function renderAccountDeleteEmail(props: Props) { - - {previewText} - - + + {previewText} + {t(`${namespace}:hello`, { displayName: props.userDisplayName, })} - {t(`${namespace}:paragraph1`, { productName: props.productName, })} - {t(`${namespace}:paragraph2`)} - {t(`${namespace}:paragraph3`, { productName: props.productName, })} - {t(`${namespace}:paragraph4`, { productName: props.productName, diff --git a/packages/email-templates/src/emails/all-results-received.email.tsx b/packages/email-templates/src/emails/all-results-received.email.tsx index 0243fc4..0083376 100644 --- a/packages/email-templates/src/emails/all-results-received.email.tsx +++ b/packages/email-templates/src/emails/all-results-received.email.tsx @@ -5,7 +5,7 @@ import { Preview, Tailwind, Text, - render + render, } from '@react-email/components'; import { BodyStyle } from '../components/body-style'; @@ -46,11 +46,10 @@ export async function renderAllResultsReceivedEmail({ - - {previewText} - - + + {previewText} + {t(`${namespace}:hello`)} @@ -62,7 +61,6 @@ export async function renderAllResultsReceivedEmail({ > {t(`${namespace}:linkText`)} - {t(`${namespace}:ifLinksDisabled`)}{' '} {`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`} diff --git a/packages/email-templates/src/emails/company-offer.email.tsx b/packages/email-templates/src/emails/company-offer.email.tsx index f13308c..68a5adc 100644 --- a/packages/email-templates/src/emails/company-offer.email.tsx +++ b/packages/email-templates/src/emails/company-offer.email.tsx @@ -55,23 +55,19 @@ export async function renderCompanyOfferEmail({ - - {previewText} - - + + {previewText} + {t(`${namespace}:companyName`)} {companyData.companyName} - {t(`${namespace}:contactPerson`)} {companyData.contactPerson} - {t(`${namespace}:email`)} {companyData.email} - {t(`${namespace}:phone`)} {companyData.phone || 'N/A'} diff --git a/packages/email-templates/src/emails/doctor-summary-received.email.tsx b/packages/email-templates/src/emails/doctor-summary-received.email.tsx index 69ce37e..19b2b65 100644 --- a/packages/email-templates/src/emails/doctor-summary-received.email.tsx +++ b/packages/email-templates/src/emails/doctor-summary-received.email.tsx @@ -2,6 +2,7 @@ import { Body, Head, Html, + Link, Preview, Tailwind, Text, @@ -11,7 +12,6 @@ import { import { BodyStyle } from '../components/body-style'; import CommonFooter from '../components/common-footer'; import { EmailContent } from '../components/content'; -import { EmailButton } from '../components/email-button'; import { EmailHeader } from '../components/header'; import { EmailHeading } from '../components/heading'; import { EmailWrapper } from '../components/wrapper'; @@ -20,12 +20,10 @@ import { initializeEmailI18n } from '../lib/i18n'; export async function renderDoctorSummaryReceivedEmail({ language, recipientName, - orderNr, analysisOrderId, }: { - language?: string; + language: string; recipientName: string; - orderNr: string; analysisOrderId: number; }) { const namespace = 'doctor-summary-received-email'; @@ -35,13 +33,9 @@ export async function renderDoctorSummaryReceivedEmail({ namespace: [namespace, 'common'], }); - const previewText = t(`${namespace}:previewText`, { - orderNr, - }); + const previewText = t(`${namespace}:previewText`); - const subject = t(`${namespace}:subject`, { - orderNr, - }); + const subject = t(`${namespace}:subject`); const html = await render( @@ -54,29 +48,26 @@ export async function renderDoctorSummaryReceivedEmail({ - - {previewText} - - + + {previewText} + - {t(`${namespace}:hello`, { - displayName: recipientName, - })} - - - {t(`${namespace}:summaryReceivedForOrder`, { orderNr })} + {t(`common:helloName`, { name: recipientName })} - - {t(`${namespace}:linkText`, { orderNr })} - - {t(`${namespace}:ifButtonDisabled`)}{' '} - {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} + {t(`${namespace}:p1`)}{' '} + + {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} + + {t(`${namespace}:p2`)} + {t(`${namespace}:p3`)} + {t(`${namespace}:p4`)} + diff --git a/packages/email-templates/src/emails/first-results-received.email.tsx b/packages/email-templates/src/emails/first-results-received.email.tsx index 4f9f371..40ba596 100644 --- a/packages/email-templates/src/emails/first-results-received.email.tsx +++ b/packages/email-templates/src/emails/first-results-received.email.tsx @@ -46,11 +46,10 @@ export async function renderFirstResultsReceivedEmail({ - - {previewText} - - + + {previewText} + {t(`${namespace}:hello`)} diff --git a/packages/email-templates/src/emails/invite.email.tsx b/packages/email-templates/src/emails/invite.email.tsx index e59ba72..cd91424 100644 --- a/packages/email-templates/src/emails/invite.email.tsx +++ b/packages/email-templates/src/emails/invite.email.tsx @@ -74,20 +74,17 @@ export async function renderInviteEmail(props: Props) { - - {heading} - - + + {heading} + {hello} - - {props.teamLogo && (
@@ -102,20 +99,16 @@ export async function renderInviteEmail(props: Props) {
)} - -
+
{joinTeam}
- {t(`${namespace}:copyPasteLink`)}{' '} {props.link} -
- {t(`${namespace}:invitationIntendedFor`, { invitedUserEmail: props.invitedUserEmail, diff --git a/packages/email-templates/src/emails/new-jobs-available.email.tsx b/packages/email-templates/src/emails/new-jobs-available.email.tsx index 23ca3f4..34fb7d9 100644 --- a/packages/email-templates/src/emails/new-jobs-available.email.tsx +++ b/packages/email-templates/src/emails/new-jobs-available.email.tsx @@ -6,7 +6,7 @@ import { Preview, Tailwind, Text, - render + render, } from '@react-email/components'; import { BodyStyle } from '../components/body-style'; @@ -50,11 +50,10 @@ export async function renderNewJobsAvailableEmail({ - - {previewText} - - + + {previewText} + {t(`${namespace}:hello`)} diff --git a/packages/email-templates/src/emails/order-processing.email.tsx b/packages/email-templates/src/emails/order-processing.email.tsx new file mode 100644 index 0000000..8a7afb0 --- /dev/null +++ b/packages/email-templates/src/emails/order-processing.email.tsx @@ -0,0 +1,90 @@ +import { + Body, + Head, + Html, + Preview, + Tailwind, + Text, + render, +} from '@react-email/components'; + +import { BodyStyle } from '../components/body-style'; +import CommonFooter from '../components/common-footer'; +import { EmailContent } from '../components/content'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; +import { initializeEmailI18n } from '../lib/i18n'; + +export async function renderOrderProcessingEmail({ + language, + recipientName, + partnerLocation, + isUrine, +}: { + language: string; + recipientName: string; + partnerLocation: string; + isUrine?: boolean; +}) { + const namespace = 'order-processing-email'; + + const { t } = await initializeEmailI18n({ + language, + namespace: [namespace, 'common'], + }); + + const previewText = t(`${namespace}:previewText`); + + const subject = t(`${namespace}:subject`); + + const p2 = t(`${namespace}:p2`); + const p4 = t(`${namespace}:p4`); + const p1Urine = t(`${namespace}:p1Urine`); + + const html = await render( + + + + + + {previewText} + + + + + + + {previewText} + + + {t(`common:helloName`, { name: recipientName })} + + + {t(`${namespace}:heading`)} + + {t(`${namespace}:p1`, { partnerLocation })} + + {t(`${namespace}:p3`)} + + {isUrine && ( + <> + + {t(`${namespace}:p2Urine`)} + + )} + {t(`${namespace}:p5`)} + {t(`${namespace}:p6`)} + + + + + + , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/emails/otp.email.tsx b/packages/email-templates/src/emails/otp.email.tsx index ae6db76..04a8b49 100644 --- a/packages/email-templates/src/emails/otp.email.tsx +++ b/packages/email-templates/src/emails/otp.email.tsx @@ -60,18 +60,17 @@ export async function renderOtpEmail(props: Props) { - - {heading} - - + + {heading} + {mainText} {otpText} -
+
diff --git a/packages/email-templates/src/emails/patient-first-results-received.email.tsx b/packages/email-templates/src/emails/patient-first-results-received.email.tsx new file mode 100644 index 0000000..adeac31 --- /dev/null +++ b/packages/email-templates/src/emails/patient-first-results-received.email.tsx @@ -0,0 +1,81 @@ +import { + Body, + Head, + Html, + Link, + Preview, + Tailwind, + Text, + render, +} from '@react-email/components'; + +import { BodyStyle } from '../components/body-style'; +import CommonFooter from '../components/common-footer'; +import { EmailContent } from '../components/content'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; +import { initializeEmailI18n } from '../lib/i18n'; + +export async function renderPatientFirstResultsReceivedEmail({ + language, + recipientName, + analysisOrderId, +}: { + language: string; + recipientName: string; + analysisOrderId: number; +}) { + const namespace = 'patient-first-results-received-email'; + + const { t } = await initializeEmailI18n({ + language, + namespace: [namespace, 'common'], + }); + + const previewText = t(`${namespace}:previewText`); + + const subject = t(`${namespace}:subject`); + + const html = await render( + + + + + + {previewText} + + + + + + + {previewText} + + + {t(`common:helloName`, { name: recipientName })} + + + {t(`${namespace}:p1`)}{' '} + + {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} + + + {t(`${namespace}:p2`)} + {t(`${namespace}:p3`)} + {t(`${namespace}:p4`)} + + + + + + , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/emails/patient-full-results-received.email.tsx b/packages/email-templates/src/emails/patient-full-results-received.email.tsx new file mode 100644 index 0000000..6f15224 --- /dev/null +++ b/packages/email-templates/src/emails/patient-full-results-received.email.tsx @@ -0,0 +1,82 @@ +import { + Body, + Head, + Html, + Link, + Preview, + Tailwind, + Text, + render, +} from '@react-email/components'; + +import { BodyStyle } from '../components/body-style'; +import CommonFooter from '../components/common-footer'; +import { EmailContent } from '../components/content'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; +import { initializeEmailI18n } from '../lib/i18n'; + +export async function renderPatientFullResultsReceivedEmail({ + language, + recipientName, + analysisOrderId, +}: { + language: string; + recipientName: string; + analysisOrderId: number; +}) { + const namespace = 'patient-full-results-received-email'; + + const { t } = await initializeEmailI18n({ + language, + namespace: [namespace, 'common'], + }); + + const previewText = t(`${namespace}:previewText`); + + const subject = t(`${namespace}:subject`); + + const html = await render( + + + + + + {previewText} + + + + + + + {previewText} + + + {t(`common:helloName`, { name: recipientName })} + + + + {t(`${namespace}:p1`)}{' '} + + {`${process.env.NEXT_PUBLIC_SITE_URL}/home/analysis-results/${analysisOrderId}`} + + + {t(`${namespace}:p2`)} + {t(`${namespace}:p3`)} + + + + + + + , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/emails/synlab.email.tsx b/packages/email-templates/src/emails/synlab.email.tsx index 29ff7d5..3605ac7 100644 --- a/packages/email-templates/src/emails/synlab.email.tsx +++ b/packages/email-templates/src/emails/synlab.email.tsx @@ -34,7 +34,7 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) { const previewText = t(`${namespace}:previewText`, { analysisPackageName: props.analysisPackageName, }); - + const subject = t(`${namespace}:subject`, { analysisPackageName: props.analysisPackageName, }); @@ -70,15 +70,13 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) { - - {heading} - - + + {heading} + {hello} - {lines.map((line, index) => ( ))} - diff --git a/packages/email-templates/src/index.ts b/packages/email-templates/src/index.ts index 83e3021..cae4d3f 100644 --- a/packages/email-templates/src/index.ts +++ b/packages/email-templates/src/index.ts @@ -7,3 +7,6 @@ export * from './emails/doctor-summary-received.email'; export * from './emails/new-jobs-available.email'; export * from './emails/first-results-received.email'; export * from './emails/all-results-received.email'; +export * from './emails/order-processing.email'; +export * from './emails/patient-first-results-received.email'; +export * from './emails/patient-full-results-received.email'; diff --git a/packages/email-templates/src/locales/en/doctor-summary-received-email.json b/packages/email-templates/src/locales/en/doctor-summary-received-email.json index ebefe9b..ed17242 100644 --- a/packages/email-templates/src/locales/en/doctor-summary-received-email.json +++ b/packages/email-templates/src/locales/en/doctor-summary-received-email.json @@ -1,8 +1,8 @@ { - "subject": "Doctor feedback to order {{orderNr}} received", - "previewText": "A doctor has submitted feedback on your analysis results.", - "hello": "Hello {{displayName}},", - "summaryReceivedForOrder": "A doctor has submitted feedback to your analysis results from order {{orderNr}}.", - "linkText": "View summary", - "ifButtonDisabled": "If clicking the button does not work, copy this link to your browser's url field:" -} \ No newline at end of file + "subject": "Doctor's summary has arrived", + "previewText": "The doctor has prepared a summary of the test results.", + "p1": "The doctor's summary has arrived:", + "p2": "It is recommended to have a comprehensive health check-up regularly, at least once a year, if you wish to maintain an active and fulfilling lifestyle.", + "p3": "MedReport makes it easy, convenient, and fast to view health data in one place and order health check-ups.", + "p4": "SYNLAB customer support phone: 17123" +} diff --git a/packages/email-templates/src/locales/en/order-processing-email.json b/packages/email-templates/src/locales/en/order-processing-email.json new file mode 100644 index 0000000..b3472f0 --- /dev/null +++ b/packages/email-templates/src/locales/en/order-processing-email.json @@ -0,0 +1,13 @@ +{ + "subject": "The referral has been sent to the laboratory. Please go to give samples.", + "heading": "Thank you for your order!", + "previewText": "The referral for tests has been sent to the laboratory.", + "p1": "The referral for tests has been sent to the laboratory digitally. Please go to give samples: {{partnerLocation}}.", + "p2": "If you are unable to go to the selected location to give samples, you may visit any other sampling point convenient for you - see locations and opening hours.", + "p3": "It is recommended to give samples preferably in the morning (before 12:00) and on an empty stomach without drinking or eating (you may drink water).", + "p4": "At the sampling point, please choose in the queue system: under referrals select specialist referral.", + "p5": "If you have any additional questions, please do not hesitate to contact us.", + "p6": "SYNLAB customer support phone: 17123", + "p1Urine": "The tests include a urine test. For the urine test, please collect the first morning urine.", + "p2Urine": "You can buy a sample container at the pharmacy and bring the sample with you (procedure performed at home), or ask for one at the sampling point (procedure performed in the point’s restroom)." +} diff --git a/packages/email-templates/src/locales/en/patient-first-results-received-email.json b/packages/email-templates/src/locales/en/patient-first-results-received-email.json new file mode 100644 index 0000000..1a77006 --- /dev/null +++ b/packages/email-templates/src/locales/en/patient-first-results-received-email.json @@ -0,0 +1,8 @@ +{ + "subject": "The first ordered test results have arrived", + "previewText": "The first test results have arrived.", + "p1": "The first test results have arrived:", + "p2": "We will send the next notification once all test results have been received in the system.", + "p3": "If you have any additional questions, please feel free to contact us.", + "p4": "SYNLAB customer support phone: 17123" +} diff --git a/packages/email-templates/src/locales/en/patient-full-results-received-email.json b/packages/email-templates/src/locales/en/patient-full-results-received-email.json new file mode 100644 index 0000000..8fd6ed2 --- /dev/null +++ b/packages/email-templates/src/locales/en/patient-full-results-received-email.json @@ -0,0 +1,7 @@ +{ + "subject": "All ordered test results have arrived. Awaiting doctor's summary.", + "previewText": "All test results have arrived.", + "p1": "All test results have arrived:", + "p2": "We will send the next notification once the doctor's summary has been prepared.", + "p3": "SYNLAB customer support phone: 17123" +} diff --git a/packages/email-templates/src/locales/et/common.json b/packages/email-templates/src/locales/et/common.json index fc58e08..8b41d33 100644 --- a/packages/email-templates/src/locales/et/common.json +++ b/packages/email-templates/src/locales/et/common.json @@ -4,5 +4,7 @@ "lines2": "E-mail: info@medreport.ee", "lines3": "Klienditugi: +372 5887 1517", "lines4": "www.medreport.ee" - } + }, + "helloName": "Tere, {{name}}", + "hello": "Tere" } \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/doctor-summary-received-email.json b/packages/email-templates/src/locales/et/doctor-summary-received-email.json index e7efdc3..9e81ab5 100644 --- a/packages/email-templates/src/locales/et/doctor-summary-received-email.json +++ b/packages/email-templates/src/locales/et/doctor-summary-received-email.json @@ -1,8 +1,8 @@ { - "subject": "Saabus arsti kokkuvõtte tellimusele {{orderNr}}", - "previewText": "Arst on saatnud kokkuvõtte sinu analüüsitulemustele.", - "hello": "Tere, {{displayName}}", - "summaryReceivedForOrder": "Arst on koostanud selgitava kokkuvõtte sinu tellitud analüüsidele.", - "linkText": "Vaata kokkuvõtet", - "ifButtonDisabled": "Kui nupule vajutamine ei toimi, kopeeri see link oma brauserisse:" + "subject": "Arsti kokkuvõte on saabunud", + "previewText": "Arst on koostanud kokkuvõte analüüsitulemustele.", + "p1": "Arsti kokkuvõte on saabunud:", + "p2": "Põhjalikul terviseuuringul on soovituslik käia regulaarselt, aga vähemalt üks kord aastas, kui soovite säilitada aktiivset ja täisväärtuslikku elustiili.", + "p3": "MedReport aitab lihtsalt, mugavalt ja kiirelt terviseandmeid ühest kohast vaadata ning tellida terviseuuringuid.", + "p4": "SYNLAB klienditoe telefon: 17123" } \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/order-processing-email.json b/packages/email-templates/src/locales/et/order-processing-email.json new file mode 100644 index 0000000..a9e57c0 --- /dev/null +++ b/packages/email-templates/src/locales/et/order-processing-email.json @@ -0,0 +1,13 @@ +{ + "subject": "Saatekiri on saadetud laborisse. Palun mine proove andma.", + "heading": "Täname tellimuse eest!", + "previewText": "Saatekiri uuringute tegemiseks on saadetud laborisse.", + "p1": "Saatekiri uuringute tegemiseks on saadetud laborisse digitaalselt. Palun mine proove andma: {{partnerLocation}}.", + "p2": "Kui Teil ei ole võimalik valitud asukohta minna proove andma, siis võite minna endale sobivasse proovivõtupunkti - vaata asukohti ja lahtiolekuaegasid.", + "p3": "Soovituslik on proove anda pigem hommikul (enne 12:00) ning söömata ja joomata (vett võib juua).", + "p4": "Proovivõtupunktis valige järjekorrasüsteemis: saatekirjad alt eriarsti saatekiri", + "p5": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.", + "p6": "SYNLAB klienditoe telefon: 17123", + "p1Urine": "Analüüsides on ette nähtud uriinianalüüs. Uriinianalüüsiks võta hommikune esmane uriin.", + "p2Urine": "Proovitopsi võib soetada apteegist ja analüüsi kaasa võtta (teostada protseduur kodus) või küsida proovivõtupunktist (teostada protseduur proovipunkti wc-s)." +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/patient-first-results-received-email.json b/packages/email-templates/src/locales/et/patient-first-results-received-email.json new file mode 100644 index 0000000..7d87e78 --- /dev/null +++ b/packages/email-templates/src/locales/et/patient-first-results-received-email.json @@ -0,0 +1,8 @@ +{ + "subject": "Saabusid tellitud uuringute esimesed tulemused", + "previewText": "Esimesed uuringute tulemused on saabunud.", + "p1": "Esimesed uuringute tulemused on saabunud:", + "p2": "Saadame järgmise teavituse, kui kõik uuringute vastused on saabunud süsteemi.", + "p3": "Juhul kui tekkis lisaküsimusi, siis võtke julgelt ühendust.", + "p4": "SYNLAB klienditoe telefon: 17123" +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/patient-full-results-received-email.json b/packages/email-templates/src/locales/et/patient-full-results-received-email.json new file mode 100644 index 0000000..4a1de1a --- /dev/null +++ b/packages/email-templates/src/locales/et/patient-full-results-received-email.json @@ -0,0 +1,7 @@ +{ + "subject": "Kõikide tellitud uuringute tulemused on saabunud. Ootab arsti kokkuvõtet.", + "previewText": "Kõikide uuringute tulemused on saabunud.", + "p1": "Kõikide uuringute tulemused on saabunud:", + "p2": "Saadame järgmise teavituse kui arsti kokkuvõte on koostatud.", + "p3": "SYNLAB klienditoe telefon: 17123" +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/ru/doctor-summary-received-email.json b/packages/email-templates/src/locales/ru/doctor-summary-received-email.json index 09beb43..e233f55 100644 --- a/packages/email-templates/src/locales/ru/doctor-summary-received-email.json +++ b/packages/email-templates/src/locales/ru/doctor-summary-received-email.json @@ -1,8 +1,8 @@ { - "subject": "Получено заключение врача по заказу {{orderNr}}", - "previewText": "Врач отправил заключение по вашим результатам анализа.", - "hello": "Здравствуйте, {{displayName}}", - "summaryReceivedForOrder": "Врач подготовил пояснительное заключение по заказанным вами анализам.", - "linkText": "Посмотреть заключение", - "ifButtonDisabled": "Если кнопка не работает, скопируйте эту ссылку в ваш браузер:" + "subject": "Заключение врача готово", + "previewText": "Врач подготовил заключение по результатам анализов.", + "p1": "Заключение врача готово:", + "p2": "Рекомендуется проходить комплексное обследование регулярно, но как минимум один раз в год, если вы хотите сохранить активный и полноценный образ жизни.", + "p3": "MedReport позволяет легко, удобно и быстро просматривать медицинские данные в одном месте и заказывать обследования.", + "p4": "Телефон службы поддержки SYNLAB: 17123" } \ No newline at end of file diff --git a/packages/email-templates/src/locales/ru/order-processing-email.json b/packages/email-templates/src/locales/ru/order-processing-email.json new file mode 100644 index 0000000..3a5d6ac --- /dev/null +++ b/packages/email-templates/src/locales/ru/order-processing-email.json @@ -0,0 +1,13 @@ +{ + "subject": "Направление отправлено в лабораторию. Пожалуйста, сдайте анализы.", + "heading": "Спасибо за заказ!", + "previewText": "Направление на обследование отправлено в лабораторию.", + "p1": "Направление на обследование было отправлено в лабораторию в цифровом виде. Пожалуйста, сдайте анализы: {{partnerLocation}}.", + "p2": "Если у вас нет возможности прийти в выбранный пункт сдачи анализов, вы можете обратиться в любой удобный для вас пункт – посмотреть адреса и часы работы.", + "p3": "Рекомендуется сдавать анализы утром (до 12:00) натощак, без еды и напитков (разрешается пить воду).", + "p4": "В пункте сдачи анализов выберите в системе очереди: в разделе направлениянаправление от специалиста.", + "p5": "Если у вас возникли дополнительные вопросы, пожалуйста, свяжитесь с нами.", + "p6": "Телефон службы поддержки SYNLAB: 17123", + "p1Urine": "В обследование входит анализ мочи. Для анализа необходимо собрать первую утреннюю мочу.", + "p2Urine": "Контейнер можно приобрести в аптеке и принести образец с собой (процедура проводится дома) или взять контейнер в пункте сдачи (процедура проводится в туалете пункта)." +} diff --git a/packages/email-templates/src/locales/ru/patient-first-results-received-email.json b/packages/email-templates/src/locales/ru/patient-first-results-received-email.json new file mode 100644 index 0000000..975934f --- /dev/null +++ b/packages/email-templates/src/locales/ru/patient-first-results-received-email.json @@ -0,0 +1,8 @@ +{ + "subject": "Поступили первые результаты заказанных исследований", + "previewText": "Первые результаты исследований поступили.", + "p1": "Первые результаты исследований поступили:", + "p2": "Мы отправим следующее уведомление, когда все результаты исследований будут получены в системе.", + "p3": "Если у вас возникнут дополнительные вопросы, пожалуйста, свяжитесь с нами.", + "p4": "Телефон службы поддержки SYNLAB: 17123" +} diff --git a/packages/email-templates/src/locales/ru/patient-full-results-received-email.json b/packages/email-templates/src/locales/ru/patient-full-results-received-email.json new file mode 100644 index 0000000..e47f161 --- /dev/null +++ b/packages/email-templates/src/locales/ru/patient-full-results-received-email.json @@ -0,0 +1,7 @@ +{ + "subject": "Все заказанные результаты исследований поступили. Ожидается заключение врача.", + "previewText": "Все результаты исследований поступили.", + "p1": "Все результаты исследований поступили:", + "p2": "Мы отправим следующее уведомление, когда заключение врача будет подготовлено.", + "p3": "Телефон службы поддержки SYNLAB: 17123" +} \ No newline at end of file diff --git a/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts b/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts index 4553578..610b70a 100644 --- a/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts +++ b/packages/features/doctor/src/lib/server/actions/doctor-server-actions.ts @@ -126,7 +126,7 @@ export const giveFeedbackAction = doctorAction( if (isCompleted) { await createNotificationLog({ - action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED, + action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED, status: 'SUCCESS', relatedRecordId: analysisOrderId, }); @@ -136,7 +136,7 @@ export const giveFeedbackAction = doctorAction( } catch (e: any) { if (isCompleted) { await createNotificationLog({ - action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED, + action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED, status: 'FAIL', comment: e?.message, relatedRecordId: analysisOrderId, diff --git a/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts b/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts index 329d846..db8e2be 100644 --- a/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts +++ b/packages/features/doctor/src/lib/server/schema/doctor-analysis.schema.ts @@ -1,9 +1,9 @@ -import z from 'zod/v3'; import { Database } from '@kit/supabase/database'; +import z from 'zod'; export const doctorJobSelectSchema = z.object({ - userId: z.string().uuid(), + userId: z.uuid(), analysisOrderId: z.number(), }); export type DoctorJobSelect = z.infer; diff --git a/packages/features/notifications/package.json b/packages/features/notifications/package.json index 5355d69..df31c57 100644 --- a/packages/features/notifications/package.json +++ b/packages/features/notifications/package.json @@ -11,7 +11,8 @@ "exports": { "./api": "./src/server/api.ts", "./components": "./src/components/index.ts", - "./hooks": "./src/hooks/index.ts" + "./hooks": "./src/hooks/index.ts", + "./webhooks/*": "./src/server/services/webhooks/*.ts" }, "devDependencies": { "@kit/eslint-config": "workspace:*", diff --git a/packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts b/packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts new file mode 100644 index 0000000..643b1b7 --- /dev/null +++ b/packages/features/notifications/src/server/services/webhooks/analysis-order-notifications.service.ts @@ -0,0 +1,273 @@ +import { + renderAllResultsReceivedEmail, + renderFirstResultsReceivedEmail, + renderOrderProcessingEmail, + renderPatientFirstResultsReceivedEmail, + renderPatientFullResultsReceivedEmail, +} from '@kit/email-templates'; +import { getLogger } from '@kit/shared/logger'; +import { getFullName } from '@kit/shared/utils'; +import { Database } from '@kit/supabase/database'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +import { + getAssignedDoctorAccount, + getDoctorAccounts, + getUserContactAdmin, +} from '~/lib/services/account.service'; +import { + NotificationAction, + createNotificationLog, +} from '~/lib/services/audit/notificationEntries.service'; +import { + EmailRenderer, + sendEmailFromTemplate, +} from '~/lib/services/mailer.service'; + +type AnalysisOrder = Database['medreport']['Tables']['analysis_orders']['Row']; + +export function createAnalysisOrderWebhooksService() { + return new AnalysisOrderWebhooksService(); +} + +class AnalysisOrderWebhooksService { + private readonly namespace = 'analysis_orders.webhooks'; + + async handleStatusChangeWebhook(analysisOrder: AnalysisOrder) { + const logger = await getLogger(); + + const ctx = { + analysisOrderId: analysisOrder.id, + namespace: this.namespace, + }; + + logger.info(ctx, 'Received status change update. Processing...'); + let actions: NotificationAction[] = []; + try { + if (analysisOrder.status === 'PROCESSING') { + actions = [NotificationAction.PATIENT_ORDER_PROCESSING]; + await this.sendProcessingNotification(analysisOrder); + } + + if (analysisOrder.status === 'PARTIAL_ANALYSIS_RESPONSE') { + actions = [ + NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED, + NotificationAction.DOCTOR_NEW_JOBS, + ]; + + await this.sendPartialAnalysisResultsNotifications(analysisOrder); + } + + if (analysisOrder.status === 'FULL_ANALYSIS_RESPONSE') { + actions = [ + NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED, + NotificationAction.PATIENT_FULL_RESULTS_RECEIVED, + ]; + await this.sendFullAnalysisResultsNotifications(analysisOrder); + } + + if (actions.length) { + return logger.info(ctx, 'Status change notifications sent.'); + } + + logger.info(ctx, 'Status change processed. No notifications to send.'); + } catch (e: any) { + if (actions.length) + await Promise.all( + actions.map((action) => + createNotificationLog({ + action, + status: 'FAIL', + comment: e?.message, + relatedRecordId: analysisOrder.id, + }), + ), + ); + logger.error( + ctx, + `Error while processing status change: ${JSON.stringify(e)}`, + ); + } + } + + async sendProcessingNotification(analysisOrder: AnalysisOrder) { + const logger = await getLogger(); + const supabase = getSupabaseServerAdminClient(); + + const userContact = await getUserContactAdmin(analysisOrder.user_id); + + if (!userContact?.email) { + await createNotificationLog({ + action: NotificationAction.PATIENT_ORDER_PROCESSING, + status: 'FAIL', + comment: 'No email found for ' + analysisOrder.user_id, + relatedRecordId: analysisOrder.id, + }); + logger.warn( + { analysisOrderId: analysisOrder.id, namespace: this.namespace }, + 'No email found ', + ); + return; + } + + const [{ data: medusaOrder }, { data: analysisElements }] = + await Promise.all([ + supabase + .from('order') + .select('id,metadata') + .eq('id', analysisOrder.medusa_order_id) + .single() + .throwOnError(), + supabase + .schema('medreport') + .from('analysis_elements') + .select('materialGroups:material_groups') + .in('id', analysisOrder.analysis_element_ids ?? []) + .throwOnError(), + ]); + + let isUrine = false; + for (const analysisElement of analysisElements ?? []) { + logger.info({ group: analysisElement.materialGroups ?? [] }); + + const containsUrineSample = (analysisElement.materialGroups ?? [])?.some( + (element) => + (element as { Materjal?: { MaterjaliNimi: string } })?.Materjal + ?.MaterjaliNimi === 'Uriin', + ); + + if (containsUrineSample) { + isUrine = true; + break; + } + } + + const orderMetadata = medusaOrder.metadata as { + partner_location_name?: string; + }; + + await sendEmailFromTemplate( + renderOrderProcessingEmail, + { + language: userContact.preferred_locale ?? 'et', + recipientName: getFullName(userContact.name, userContact.last_name), + partnerLocation: orderMetadata.partner_location_name ?? 'SYNLAB', + isUrine, + }, + userContact.email, + ); + + return createNotificationLog({ + action: NotificationAction.PATIENT_ORDER_PROCESSING, + status: 'SUCCESS', + relatedRecordId: analysisOrder.id, + }); + } + + async sendPatientUpdateNotification( + analysisOrder: AnalysisOrder, + template: EmailRenderer, + action: NotificationAction, + ) { + const logger = await getLogger(); + + const userContact = await getUserContactAdmin(analysisOrder.user_id); + + if (userContact?.email) { + await sendEmailFromTemplate( + template, + { + analysisOrderId: analysisOrder.id, + recipientName: getFullName(userContact.name, userContact.last_name), + language: userContact.preferred_locale ?? 'et', + }, + userContact.email, + ); + await createNotificationLog({ + action, + status: 'SUCCESS', + relatedRecordId: analysisOrder.id, + }); + logger.info( + { analysisOrderId: analysisOrder.id, namespace: this.namespace }, + 'Sent notification email', + ); + } else { + await createNotificationLog({ + action, + status: 'FAIL', + comment: 'No email found for ' + analysisOrder.user_id, + relatedRecordId: analysisOrder.id, + }); + logger.warn( + { analysisOrderId: analysisOrder.id, namespace: this.namespace }, + 'No email found ', + ); + } + } + + async sendPartialAnalysisResultsNotifications(analysisOrder: AnalysisOrder) { + const logger = await getLogger(); + + await this.sendPatientUpdateNotification( + analysisOrder, + renderPatientFirstResultsReceivedEmail, + NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED, + ); + + const doctorAccounts = await getDoctorAccounts(); + const doctorEmails: string[] = doctorAccounts + .map(({ email }) => email) + .filter((email): email is string => !!email); + + await sendEmailFromTemplate( + renderFirstResultsReceivedEmail, + { + analysisOrderId: analysisOrder.id, + language: 'et', + }, + doctorEmails, + ); + + logger.info( + { analysisOrderId: analysisOrder.id, namespace: this.namespace }, + 'Sent out partial analysis results notifications for doctors', + ); + + await createNotificationLog({ + action: NotificationAction.DOCTOR_NEW_JOBS, + status: 'SUCCESS', + relatedRecordId: analysisOrder.id, + }); + } + + async sendFullAnalysisResultsNotifications(analysisOrder: AnalysisOrder) { + await this.sendPatientUpdateNotification( + analysisOrder, + renderPatientFullResultsReceivedEmail, + NotificationAction.PATIENT_FULL_RESULTS_RECEIVED, + ); + + const doctorAccount = await getAssignedDoctorAccount(analysisOrder.id); + const assignedDoctorEmail = doctorAccount?.email; + + if (!assignedDoctorEmail) { + return; + } + + await sendEmailFromTemplate( + renderAllResultsReceivedEmail, + { + analysisOrderId: analysisOrder.id, + language: 'et', + }, + assignedDoctorEmail, + ); + + return createNotificationLog({ + action: NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED, + status: 'SUCCESS', + relatedRecordId: analysisOrder.id, + }); + } +} From 1283094a91458334fa93a2b7ce518e02ce2278fe Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Tue, 9 Sep 2025 11:56:52 +0300 Subject: [PATCH 2/9] newer pino-pretty@13.1.1 has new strip-json-comments version that conflicts with others and causes error --- package.json | 2 +- pnpm-lock.yaml | 75 +++++++++++++++++++++++++++++++------------------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/package.json b/package.json index 18d2a21..33ec639 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "babel-plugin-react-compiler": "19.1.0-rc.2", "cssnano": "^7.0.7", "dotenv": "^16.5.0", - "pino-pretty": "^13.0.0", + "pino-pretty": "13.0.0", "prettier": "^3.5.3", "supabase": "^2.30.4", "tailwindcss": "4.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4634f0..77d0440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,8 +220,8 @@ importers: specifier: ^16.5.0 version: 16.6.1 pino-pretty: - specifier: ^13.0.0 - version: 13.1.1 + specifier: 13.0.0 + version: 13.0.0 prettier: specifier: ^3.5.3 version: 3.6.2 @@ -478,10 +478,10 @@ importers: dependencies: '@keystatic/core': specifier: 0.5.47 - version: 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@keystatic/next': specifier: ^5.0.4 - version: 5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + version: 5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@markdoc/markdoc': specifier: ^0.5.1 version: 0.5.4(@types/react@19.1.4)(react@19.1.0) @@ -1272,7 +1272,7 @@ importers: dependencies: '@sentry/nextjs': specifier: ^9.19.0 - version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3) + version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3) import-in-the-middle: specifier: 1.13.2 version: 1.13.2 @@ -8893,8 +8893,8 @@ packages: pino-abstract-transport@2.0.0: resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} - pino-pretty@13.1.1: - resolution: {integrity: sha512-TNNEOg0eA0u+/WuqH0MH0Xui7uqVk9D74ESOpjtebSQYbNWJk/dIxCXIxFsNfeN53JmtWqYHP2OrIZjT/CBEnA==} + pino-pretty@13.0.0: + resolution: {integrity: sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==} hasBin: true pino-std-serializers@7.0.0: @@ -9595,8 +9595,8 @@ packages: scroll-into-view-if-needed@3.1.0: resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} - secure-json-parse@4.0.0: - resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} selderee@0.11.0: resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} @@ -9820,10 +9820,6 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strip-json-comments@5.0.3: - resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} - engines: {node: '>=14.16'} - stripe@18.5.0: resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==} engines: {node: '>=12.*'} @@ -11461,7 +11457,7 @@ snapshots: '@juggle/resize-observer@3.4.0': {} - '@keystar/ui@0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@keystar/ui@0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.6 '@emotion/css': 11.13.5 @@ -11554,18 +11550,18 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) optionalDependencies: - next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) transitivePeerDependencies: - supports-color - '@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.6 '@braintree/sanitize-url': 6.0.4 '@emotion/weak-memoize': 0.3.1 '@floating-ui/react': 0.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@internationalized/string': 3.2.7 - '@keystar/ui': 0.7.19(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@keystar/ui': 0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@markdoc/markdoc': 0.4.0(@types/react@19.1.4)(react@19.1.0) '@react-aria/focus': 3.20.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@react-aria/i18n': 3.12.10(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -11636,13 +11632,13 @@ snapshots: - next - supports-color - '@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + '@keystatic/next@5.0.4(@keystatic/core@0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@babel/runtime': 7.27.6 - '@keystatic/core': 0.5.47(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@keystatic/core': 0.5.47(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/react': 19.1.4 chokidar: 3.6.0 - next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) server-only: 0.0.1 @@ -17236,7 +17232,7 @@ snapshots: '@sentry/core@9.46.0': {} - '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)': + '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.101.3)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.34.0 @@ -17249,7 +17245,7 @@ snapshots: '@sentry/vercel-edge': 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0)) '@sentry/webpack-plugin': 3.5.0(webpack@5.101.3) chalk: 3.0.0 - next: 15.3.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) resolve: 1.22.8 rollup: 4.35.0 stacktrace-parser: 0.1.11 @@ -21273,6 +21269,31 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@next/env': 15.5.2 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001723 + postcss: 8.4.31 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@19.1.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.2 + '@next/swc-darwin-x64': 15.5.2 + '@next/swc-linux-arm64-gnu': 15.5.2 + '@next/swc-linux-arm64-musl': 15.5.2 + '@next/swc-linux-x64-gnu': 15.5.2 + '@next/swc-linux-x64-musl': 15.5.2 + '@next/swc-win32-arm64-msvc': 15.5.2 + '@next/swc-win32-x64-msvc': 15.5.2 + '@opentelemetry/api': 1.9.0 + babel-plugin-react-compiler: 19.1.0-rc.2 + sharp: 0.34.3 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -21506,7 +21527,7 @@ snapshots: dependencies: split2: 4.2.0 - pino-pretty@13.1.1: + pino-pretty@13.0.0: dependencies: colorette: 2.0.20 dateformat: 4.6.3 @@ -21518,9 +21539,9 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 2.0.0 pump: 3.0.3 - secure-json-parse: 4.0.0 + secure-json-parse: 2.7.0 sonic-boom: 4.2.0 - strip-json-comments: 5.0.3 + strip-json-comments: 3.1.1 pino-std-serializers@7.0.0: {} @@ -22474,7 +22495,7 @@ snapshots: dependencies: compute-scroll-into-view: 3.1.1 - secure-json-parse@4.0.0: {} + secure-json-parse@2.7.0: {} selderee@0.11.0: dependencies: @@ -22796,8 +22817,6 @@ snapshots: strip-json-comments@3.1.1: {} - strip-json-comments@5.0.3: {} - stripe@18.5.0(@types/node@24.3.0): dependencies: qs: 6.14.0 From 85c72e777ba7433adff8d331a29d986262ce7cf0 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 12:05:03 +0300 Subject: [PATCH 3/9] add missing translations --- public/locales/en/cart.json | 3 ++- public/locales/et/cart.json | 3 ++- public/locales/ru/cart.json | 11 +++++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/public/locales/en/cart.json b/public/locales/en/cart.json index 84221a4..0c9af9a 100644 --- a/public/locales/en/cart.json +++ b/public/locales/en/cart.json @@ -64,7 +64,8 @@ "orderDate": "Order date", "orderNumber": "Order number", "orderStatus": "Order status", - "paymentStatus": "Payment status" + "paymentStatus": "Payment status", + "discount": "Discount" }, "montonioCallback": { "title": "Montonio checkout", diff --git a/public/locales/et/cart.json b/public/locales/et/cart.json index a7bcb19..fe87f7b 100644 --- a/public/locales/et/cart.json +++ b/public/locales/et/cart.json @@ -68,7 +68,8 @@ "orderDate": "Tellimuse kuupäev", "orderNumber": "Tellimuse number", "orderStatus": "Tellimuse olek", - "paymentStatus": "Makse olek" + "paymentStatus": "Makse olek", + "discount": "Soodus" }, "montonioCallback": { "title": "Montonio makseprotsess", diff --git a/public/locales/ru/cart.json b/public/locales/ru/cart.json index 9aaeb3f..7f3c536 100644 --- a/public/locales/ru/cart.json +++ b/public/locales/ru/cart.json @@ -28,7 +28,13 @@ "label": "Добавить промокод", "apply": "Применить", "subtitle": "Если хотите, можете добавить промокод", - "placeholder": "Введите промокод" + "placeholder": "Введите промокод", + "remove": "Удалить промокод", + "appliedCodes": "Примененные промокоды:", + "removeError": "Не удалось удалить промокод", + "removeSuccess": "Промокод удален", + "addError": "Не удалось применить промокод", + "addSuccess": "Промокод применен" }, "items": { "synlabAnalyses": { @@ -61,7 +67,8 @@ "orderDate": "Дата заказа", "orderNumber": "Номер заказа", "orderStatus": "Статус заказа", - "paymentStatus": "Статус оплаты" + "paymentStatus": "Статус оплаты", + "discount": "Скидка" }, "montonioCallback": { "title": "Процесс оплаты Montonio", From 514cb3bf7bbd027c897a60f81d55968f56abc9e0 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Tue, 9 Sep 2025 12:36:19 +0300 Subject: [PATCH 4/9] redirect homepage to website in production. support lang param --- middleware.ts | 12 ++++++++++++ packages/ui/src/makerkit/language-selector.tsx | 1 + 2 files changed, 13 insertions(+) diff --git a/middleware.ts b/middleware.ts index 1507e71..0c0e3b3 100644 --- a/middleware.ts +++ b/middleware.ts @@ -27,6 +27,8 @@ const getUser = (request: NextRequest, response: NextResponse) => { export async function middleware(request: NextRequest) { const secureHeaders = await createResponseWithSecureHeaders(); const response = NextResponse.next(secureHeaders); + const url = new URL(request.url); + const lang = url.searchParams.get('lang'); // set a unique request ID for each request // this helps us log and trace requests @@ -35,6 +37,10 @@ export async function middleware(request: NextRequest) { // apply CSRF protection for mutating requests const csrfResponse = await withCsrfMiddleware(request, response); + if (lang) { + csrfResponse.cookies.set('lang', lang); + } + // handle patterns for specific routes const handlePattern = matchUrlPattern(request.url); @@ -176,6 +182,12 @@ function getPatterns() { return NextResponse.redirect( new URL(pathsConfig.app.home, req.nextUrl.origin).href, ); + } else { + if (process.env.NODE_ENV === 'production') { + return NextResponse.redirect( + new URL('https://medreport.ee', req.nextUrl.origin).href, + ); + } } }, }, diff --git a/packages/ui/src/makerkit/language-selector.tsx b/packages/ui/src/makerkit/language-selector.tsx index f7542f0..6ac5855 100644 --- a/packages/ui/src/makerkit/language-selector.tsx +++ b/packages/ui/src/makerkit/language-selector.tsx @@ -53,6 +53,7 @@ export function LanguageSelector({ } if (!userId) { + localStorage.setItem('lang', locale); return i18n.changeLanguage(locale); } From ebab0556ba465c6eda1906feb6106f2d9f3b3a2c Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Tue, 9 Sep 2025 12:39:00 +0300 Subject: [PATCH 5/9] change else to else if --- middleware.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/middleware.ts b/middleware.ts index 0c0e3b3..0c04a83 100644 --- a/middleware.ts +++ b/middleware.ts @@ -182,12 +182,10 @@ function getPatterns() { return NextResponse.redirect( new URL(pathsConfig.app.home, req.nextUrl.origin).href, ); - } else { - if (process.env.NODE_ENV === 'production') { - return NextResponse.redirect( - new URL('https://medreport.ee', req.nextUrl.origin).href, - ); - } + } else if (process.env.NODE_ENV === 'production') { + return NextResponse.redirect( + new URL('https://medreport.ee', req.nextUrl.origin).href, + ); } }, }, From 7d208b41f2adbfdeba904a495ebb97e9c7f8b931 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 13:42:44 +0300 Subject: [PATCH 6/9] update naming to be clearer --- app/api/order/medipost-test-response/route.ts | 8 ++++---- .../(dashboard)/cart/montonio-callback/actions.ts | 4 ++-- .../(dashboard)/order/[orderId]/confirmed/page.tsx | 4 ++-- app/home/(user)/(dashboard)/order/[orderId]/page.tsx | 4 ++-- lib/services/medipost.service.ts | 12 ++++++------ lib/services/order.service.ts | 8 ++++---- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/app/api/order/medipost-test-response/route.ts b/app/api/order/medipost-test-response/route.ts index 2302631..9ce8c41 100644 --- a/app/api/order/medipost-test-response/route.ts +++ b/app/api/order/medipost-test-response/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { getOrder } from "~/lib/services/order.service"; +import { getAnalysisOrder } 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"; @@ -14,9 +14,9 @@ export async function POST(request: Request) { const { medusaOrderId } = await request.json(); const medusaOrder = await retrieveOrder(medusaOrderId) - const medreportOrder = await getOrder({ medusaOrderId }); + const analysisOrder = await getAnalysisOrder({ medusaOrderId }); - const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); + const account = await getAccountAdmin({ primaryOwnerUserId: analysisOrder.user_id }); const orderedAnalysisElementsIds = await getOrderedAnalysisIds({ medusaOrder }); console.info(`Sending test response for order=${medusaOrderId} with ${orderedAnalysisElementsIds.length} ordered analysis elements`); @@ -30,7 +30,7 @@ export async function POST(request: Request) { orderedAnalysisElementsIds: orderedAnalysisElementsIds.map(({ analysisElementId }) => analysisElementId).filter(Boolean) as number[], orderedAnalysesIds: orderedAnalysisElementsIds.map(({ analysisId }) => analysisId).filter(Boolean) as number[], orderId: medusaOrderId, - orderCreatedAt: new Date(medreportOrder.created_at), + orderCreatedAt: new Date(analysisOrder.created_at), }); try { diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index 11eca6f..def72dd 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -7,7 +7,7 @@ import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user- 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 { createAnalysisOrder } from '~/lib/services/order.service'; import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -114,7 +114,7 @@ export async function processMontonioCallback(orderToken: string) { const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false }); const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); - const orderId = await createOrder({ medusaOrder, orderedAnalysisElements }); + const orderId = await createAnalysisOrder({ medusaOrder, orderedAnalysisElements }); const { productTypes } = await listProductTypes(); const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); diff --git a/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx index 21e1829..d72c530 100644 --- a/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx +++ b/app/home/(user)/(dashboard)/order/[orderId]/confirmed/page.tsx @@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; -import { getOrder } from '~/lib/services/order.service'; +import { getAnalysisOrder } from '~/lib/services/order.service'; import { retrieveOrder } from '@lib/data/orders'; import { pathsConfig } from '@kit/shared/config'; @@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: { }) { const params = await props.params; - const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null); + const order = await getAnalysisOrder({ orderId: Number(params.orderId) }).catch(() => null); if (!order) { redirect(pathsConfig.app.myOrders); } diff --git a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx index 4b717d5..c84fb99 100644 --- a/app/home/(user)/(dashboard)/order/[orderId]/page.tsx +++ b/app/home/(user)/(dashboard)/order/[orderId]/page.tsx @@ -4,7 +4,7 @@ import { PageBody, PageHeader } from '@kit/ui/page'; import { createI18nServerInstance } from '@/lib/i18n/i18n.server'; import { withI18n } from '~/lib/i18n/with-i18n'; -import { getOrder } from '~/lib/services/order.service'; +import { getAnalysisOrder } from '~/lib/services/order.service'; import { retrieveOrder } from '@lib/data/orders'; import { pathsConfig } from '@kit/shared/config'; @@ -27,7 +27,7 @@ async function OrderConfirmedPage(props: { }) { const params = await props.params; - const order = await getOrder({ orderId: Number(params.orderId) }).catch(() => null); + const order = await getAnalysisOrder({ orderId: Number(params.orderId) }).catch(() => null); if (!order) { redirect(pathsConfig.app.myOrders); } diff --git a/lib/services/medipost.service.ts b/lib/services/medipost.service.ts index 82db51d..a096083 100644 --- a/lib/services/medipost.service.ts +++ b/lib/services/medipost.service.ts @@ -24,7 +24,7 @@ import { XMLParser } from 'fast-xml-parser'; import { Tables } from '@kit/supabase/database'; import { createAnalysisGroup } from './analysis-group.service'; import { getSupabaseServerAdminClient } from '@/packages/supabase/src/clients/server-admin-client'; -import { getOrder, updateOrderStatus } from './order.service'; +import { getAnalysisOrder, updateAnalysisOrderStatus } from './order.service'; import { getAnalysisElements, getAnalysisElementsAdmin } from './analysis-element.service'; import { getAnalyses } from './analyses.service'; import { getAccountAdmin } from './account.service'; @@ -242,7 +242,7 @@ export async function readPrivateMessageResponse({ let order: Tables<{ schema: 'medreport' }, 'analysis_orders'>; try { - order = await getOrder({ medusaOrderId }); + order = await getAnalysisOrder({ medusaOrderId }); } catch (e) { await deletePrivateMessage(privateMessage.messageId); throw new Error(`Order not found by Medipost message ValisTellimuseId=${medusaOrderId}`); @@ -251,11 +251,11 @@ export async function readPrivateMessageResponse({ const status = await syncPrivateMessage({ messageResponse, order }); if (status.isPartial) { - await updateOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' }); + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PARTIAL_ANALYSIS_RESPONSE' }); hasAnalysisResponse = true; hasPartialAnalysisResponse = true; } else if (status.isCompleted) { - await updateOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'FULL_ANALYSIS_RESPONSE' }); await deletePrivateMessage(privateMessage.messageId); hasAnalysisResponse = true; hasFullAnalysisResponse = true; @@ -588,7 +588,7 @@ export async function sendOrderToMedipost({ medusaOrderId: string; orderedAnalysisElements: OrderedAnalysisElement[]; }) { - const medreportOrder = await getOrder({ medusaOrderId }); + const medreportOrder = await getAnalysisOrder({ medusaOrderId }); const account = await getAccountAdmin({ primaryOwnerUserId: medreportOrder.user_id }); const orderedAnalysesIds = orderedAnalysisElements @@ -668,7 +668,7 @@ export async function sendOrderToMedipost({ hasAnalysisResults: false, medusaOrderId, }); - await updateOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); + await updateAnalysisOrderStatus({ medusaOrderId, orderStatus: 'PROCESSING' }); } export async function getOrderedAnalysisIds({ diff --git a/lib/services/order.service.ts b/lib/services/order.service.ts index 487153a..f1eae85 100644 --- a/lib/services/order.service.ts +++ b/lib/services/order.service.ts @@ -5,7 +5,7 @@ import type { StoreOrder } from '@medusajs/types'; export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>; -export async function createOrder({ +export async function createAnalysisOrder({ medusaOrder, orderedAnalysisElements, }: { @@ -38,7 +38,7 @@ export async function createOrder({ return orderResult.data.id; } -export async function updateOrder({ +export async function updateAnalysisOrder({ orderId, orderStatus, }: { @@ -56,7 +56,7 @@ export async function updateOrder({ .throwOnError(); } -export async function updateOrderStatus({ +export async function updateAnalysisOrderStatus({ orderId, medusaOrderId, orderStatus, @@ -80,7 +80,7 @@ export async function updateOrderStatus({ .throwOnError(); } -export async function getOrder({ +export async function getAnalysisOrder({ medusaOrderId, orderId, }: { From 165d44b13fed45ee39d00ae1cd4c562077c53208 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 14:02:34 +0300 Subject: [PATCH 7/9] prepare montonio callback logic to send email for individual analysis order - skip confusing error log for orders without analysis packages --- .../cart/montonio-callback/actions.ts | 141 +++++++++++++----- 1 file changed, 102 insertions(+), 39 deletions(-) diff --git a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts index def72dd..89c8dc3 100644 --- a/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts +++ b/app/home/(user)/(dashboard)/cart/montonio-callback/actions.ts @@ -7,13 +7,15 @@ import { loadCurrentUserAccount } from "@/app/home/(user)/_lib/server/load-user- import { listProductTypes } from "@lib/data/products"; import { placeOrder, retrieveCart } from "@lib/data/cart"; import { createI18nServerInstance } from "~/lib/i18n/i18n.server"; -import { createAnalysisOrder } from '~/lib/services/order.service'; +import { createAnalysisOrder, getAnalysisOrder } from '~/lib/services/order.service'; import { getOrderedAnalysisIds, sendOrderToMedipost } from '~/lib/services/medipost.service'; import { createNotificationsApi } from '@kit/notifications/api'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; -import { AccountWithParams } from '@kit/accounts/api'; +import type { AccountWithParams } from '@kit/accounts/api'; +import type { StoreOrder } from '@medusajs/types'; const ANALYSIS_PACKAGES_TYPE_HANDLE = 'analysis-packages'; +const ANALYSIS_TYPE_HANDLE = 'synlab-analysis'; const MONTONIO_PAID_STATUS = 'PAID'; const env = () => z @@ -38,14 +40,12 @@ const sendEmail = async ({ account, email, analysisPackageName, - personName, partnerLocationName, language, }: { - account: AccountWithParams, + account: Pick, email: string, analysisPackageName: string, - personName: string, partnerLocationName: string, language: string, }) => { @@ -58,7 +58,7 @@ const sendEmail = async ({ const { html, subject } = await renderSynlabAnalysisPackageEmail({ analysisPackageName, - personName, + personName: account.name, partnerLocationName, language, }); @@ -83,9 +83,7 @@ const sendEmail = async ({ } } -export async function processMontonioCallback(orderToken: string) { - const { language } = await createI18nServerInstance(); - +async function decodeOrderToken(orderToken: string) { const secretKey = process.env.MONTONIO_SECRET_KEY as string; const decoded = jwt.verify(orderToken, secretKey, { @@ -96,50 +94,115 @@ export async function processMontonioCallback(orderToken: string) { throw new Error("Payment not successful"); } + return decoded; +} + +async function getCartByOrderToken(decoded: MontonioOrderToken) { + const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); + if (!cartId) { + throw new Error("Cart ID not found"); + } + const cart = await retrieveCart(cartId); + if (!cart) { + throw new Error("Cart not found"); + } + return cart; +} + +async function getOrderResultParameters(medusaOrder: StoreOrder) { + const { productTypes } = await listProductTypes(); + const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); + const analysisType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_TYPE_HANDLE); + + const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id); + const analysisItems = medusaOrder.items?.filter(({ product_type_id }) => product_type_id === analysisType?.id); + + return { + medusaOrderId: medusaOrder.id, + email: medusaOrder.email, + analysisPackageOrder: analysisPackageOrderItem + ? { + partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '', + analysisPackageName: analysisPackageOrderItem?.title ?? '', + } + : null, + analysisItemsOrder: Array.isArray(analysisItems) && analysisItems.length > 0 + ? analysisItems.map(({ product }) => ({ + analysisName: product?.title ?? '', + analysisId: product?.metadata?.analysisIdOriginal as string ?? '', + })) + : null, + }; +} + +async function sendAnalysisPackageOrderEmail({ + account, + email, + analysisPackageOrder, +}: { + account: AccountWithParams, + email: string, + analysisPackageOrder: { + partnerLocationName: string, + analysisPackageName: string, + }, +}) { + const { language } = await createI18nServerInstance(); + const { analysisPackageName, partnerLocationName } = analysisPackageOrder; + try { + await sendEmail({ + account: { id: account.id, name: account.name }, + email, + analysisPackageName, + partnerLocationName, + language, + }); + } catch (error) { + console.error("Failed to send email", error); + } +} + +export async function processMontonioCallback(orderToken: string) { const account = await loadCurrentUserAccount(); if (!account) { throw new Error("Account not found in context"); } try { - const [, , cartId] = decoded.merchantReferenceDisplay.split(':'); - if (!cartId) { - throw new Error("Cart ID not found"); - } + const decoded = await decodeOrderToken(orderToken); + const cart = await getCartByOrderToken(decoded); - const cart = await retrieveCart(cartId); - if (!cart) { - throw new Error("Cart not found"); - } - - const medusaOrder = await placeOrder(cartId, { revalidateCacheTags: false }); + const medusaOrder = await placeOrder(cart.id, { revalidateCacheTags: false }); const orderedAnalysisElements = await getOrderedAnalysisIds({ medusaOrder }); + + try { + const existingAnalysisOrder = await getAnalysisOrder({ medusaOrderId: medusaOrder.id }); + console.info(`Analysis order already exists for medusaOrderId=${medusaOrder.id}, orderId=${existingAnalysisOrder.id}`); + return { success: true, orderId: existingAnalysisOrder.id }; + } catch { + // ignored + } + const orderId = await createAnalysisOrder({ medusaOrder, orderedAnalysisElements }); + const orderResult = await getOrderResultParameters(medusaOrder); - const { productTypes } = await listProductTypes(); - const analysisPackagesType = productTypes.find(({ metadata }) => metadata?.handle === ANALYSIS_PACKAGES_TYPE_HANDLE); - const analysisPackageOrderItem = medusaOrder.items?.find(({ product_type_id }) => product_type_id === analysisPackagesType?.id); + const { medusaOrderId, email, analysisPackageOrder, analysisItemsOrder } = orderResult; - const orderResult = { - medusaOrderId: medusaOrder.id, - email: medusaOrder.email, - partnerLocationName: analysisPackageOrderItem?.metadata?.partner_location_name as string ?? '', - analysisPackageName: analysisPackageOrderItem?.title ?? '', - orderedAnalysisElements, - }; + if (email) { + if (analysisPackageOrder) { + await sendAnalysisPackageOrderEmail({ account, email, analysisPackageOrder }); + } else { + console.info(`Order has no analysis package, skipping email.`); + } - const { medusaOrderId, email, partnerLocationName, analysisPackageName } = orderResult; - const personName = account.name; - - if (email && analysisPackageName) { - try { - await sendEmail({ account, email, analysisPackageName, personName, partnerLocationName, language }); - } catch (error) { - console.error("Failed to send email", error); + if (analysisItemsOrder) { + // @TODO send email for separate analyses + console.warn(`Order has analysis items, but no email template exists yet`); + } else { + console.info(`Order has no analysis items, skipping email.`); } } else { - // @TODO send email for separate analyses - console.error("Missing email or analysisPackageName", orderResult); + console.error("Missing email to send order result email", orderResult); } await sendOrderToMedipost({ medusaOrderId, orderedAnalysisElements }); From 86a5931b66e4a802bec55ff61ffddfbb3bebed21 Mon Sep 17 00:00:00 2001 From: Danel Kungla Date: Tue, 9 Sep 2025 15:25:31 +0300 Subject: [PATCH 8/9] update condition for production redirect --- middleware.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/middleware.ts b/middleware.ts index 0c04a83..b000a1c 100644 --- a/middleware.ts +++ b/middleware.ts @@ -182,7 +182,11 @@ function getPatterns() { return NextResponse.redirect( new URL(pathsConfig.app.home, req.nextUrl.origin).href, ); - } else if (process.env.NODE_ENV === 'production') { + } else if ( + !['test', 'localhost'].some((pathString) => + process.env.NEXT_PUBLIC_SITE_URL?.includes(pathString), + ) + ) { return NextResponse.redirect( new URL('https://medreport.ee', req.nextUrl.origin).href, ); From 831e60c3c1a1e927d6ad155544fa4e62c9dde6f3 Mon Sep 17 00:00:00 2001 From: Karli Date: Tue, 9 Sep 2025 15:48:02 +0300 Subject: [PATCH 9/9] fix case when variant has no metadata and no package elements are displayed --- utils/medusa-product.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/utils/medusa-product.ts b/utils/medusa-product.ts index c505608..51ab2d6 100644 --- a/utils/medusa-product.ts +++ b/utils/medusa-product.ts @@ -20,12 +20,24 @@ export const getAnalysisElementMedusaProductIds = (products: Pick { const value = (product as Product)?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"'); const value_variant = (product as Product)?.variant?.metadata?.analysisElementMedusaProductIds?.replaceAll("'", '"'); + + const result: string[] = []; try { - return [...JSON.parse(value as string), ...JSON.parse(value_variant as string)]; + if (value) { + result.push(...JSON.parse(value as string)); + } + } catch (e) { + console.error("Failed to parse analysisElementMedusaProductIds from analysis package variant, possibly invalid format", e); + } + try { + if (value_variant) { + result.push(...JSON.parse(value_variant as string)); + } } catch (e) { console.error("Failed to parse analysisElementMedusaProductIds from analysis package, possibly invalid format", e); - return []; } + + return result; }) .filter(Boolean) as string[];