diff --git a/app/(public)/company-offer/_components/company-offer-form.tsx b/app/(public)/company-offer/_components/company-offer-form.tsx index 080b0dc..ebcfdcf 100644 --- a/app/(public)/company-offer/_components/company-offer-form.tsx +++ b/app/(public)/company-offer/_components/company-offer-form.tsx @@ -4,14 +4,15 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { SubmitButton } from '@kit/shared/components/ui/submit-button'; -import { sendCompanyOfferEmail } from '@/lib/services/mailer.service'; +import { sendEmailFromTemplate } from '@/lib/services/mailer.service'; import { CompanySubmitData } from '@/lib/types/company'; import { companyOfferSchema } from '@/lib/validations/company-offer.schema'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { renderCompanyOfferEmail } from '@kit/email-templates'; +import { SubmitButton } from '@kit/shared/components/ui/submit-button'; import { FormItem } from '@kit/ui/form'; import { Input } from '@kit/ui/input'; import { Label } from '@kit/ui/label'; @@ -39,7 +40,14 @@ const CompanyOfferForm = () => { }); try { - sendCompanyOfferEmail(data, language) + sendEmailFromTemplate( + renderCompanyOfferEmail, + { + companyData: data, + language, + }, + process.env.CONTACT_EMAIL!, + ) .then(() => router.push('/company-offer/success')) .catch((error) => { setIsLoading(false); diff --git a/app/api/job/handler/send-open-jobs-emails.ts b/app/api/job/handler/send-open-jobs-emails.ts new file mode 100644 index 0000000..9f09e12 --- /dev/null +++ b/app/api/job/handler/send-open-jobs-emails.ts @@ -0,0 +1,23 @@ +import { renderNewJobsAvailableEmail } from '@kit/email-templates'; + +import { getDoctorAccounts } from '~/lib/services/account.service'; +import { getOpenJobAnalysisResponseIds } from '~/lib/services/doctor-jobs.service'; +import { sendEmailFromTemplate } from '~/lib/services/mailer.service'; + +export default async function sendOpenJobsEmails() { + const analysisResponseIds = await getOpenJobAnalysisResponseIds(); + + const doctorAccounts = await getDoctorAccounts(); + const doctorEmails: string[] = doctorAccounts + .map(({ email }) => email) + .filter((email): email is string => !!email); + + await sendEmailFromTemplate( + renderNewJobsAvailableEmail, + { + language: 'et', + analysisResponseIds, + }, + doctorEmails, + ); +} diff --git a/app/api/job/send-open-jobs-emails/route.ts b/app/api/job/send-open-jobs-emails/route.ts new file mode 100644 index 0000000..c2083bf --- /dev/null +++ b/app/api/job/send-open-jobs-emails/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { + NotificationAction, + createNotificationLog, +} from '~/lib/services/audit/notificationEntries.service'; +import loadEnv from '../handler/load-env'; +import sendOpenJobsEmails from '../handler/send-open-jobs-emails'; +import validateApiKey from '../handler/validate-api-key'; + +export const POST = async (request: NextRequest) => { + loadEnv(); + + try { + validateApiKey(request); + } catch (e) { + return NextResponse.json({}, { status: 401, statusText: 'Unauthorized' }); + } + + try { + await sendOpenJobsEmails(); + console.info( + 'Successfully sent out open job notification emails to doctors.', + ); + await createNotificationLog({ + action: NotificationAction.NEW_JOBS_ALERT, + status: 'SUCCESS', + }); + return NextResponse.json( + { + message: + 'Successfully sent out open job notification emails to doctors.', + }, + { status: 200 }, + ); + } catch (e: any) { + console.error( + 'Error sending out open job notification emails to doctors.', + e, + ); + await createNotificationLog({ + action: NotificationAction.NEW_JOBS_ALERT, + status: 'FAIL', + comment: e?.message, + }); + return NextResponse.json( + { + message: 'Failed to send out open job notification emails to doctors.', + }, + { status: 500 }, + ); + } +}; diff --git a/app/doctor/_components/analysis-view.tsx b/app/doctor/_components/analysis-view.tsx index 47033a0..b22f8d4 100644 --- a/app/doctor/_components/analysis-view.tsx +++ b/app/doctor/_components/analysis-view.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQueryClient } from '@tanstack/react-query'; +import { capitalize } from 'lodash'; import { useForm } from 'react-hook-form'; import { giveFeedbackAction } from '@kit/doctor/actions/doctor-server-actions'; @@ -22,6 +23,9 @@ import { doctorAnalysisFeedbackFormSchema, } from '@kit/doctor/schema/doctor-analysis.schema'; import ConfirmationModal from '@kit/shared/components/confirmation-modal'; +import { + useCurrentLocaleLanguageNames +} from '@kit/shared/hooks'; import { getFullName } from '@kit/shared/utils'; import { useUser } from '@kit/supabase/hooks/use-user'; import { Button } from '@kit/ui/button'; @@ -57,6 +61,8 @@ export default function AnalysisView({ const { data: user } = useUser(); + const languageNames = useCurrentLocaleLanguageNames(); + const isInProgress = !!( !!feedback?.status && feedback?.doctor_user_id && @@ -191,6 +197,12 @@ export default function AnalysisView({
{patient.email}
+
+ +
+
+ {capitalize(languageNames.of(patient.preferred_locale ?? 'et'))} +
{ startTransition(async () => { const result = await fetchAction({ @@ -116,6 +121,9 @@ export default function ResultsTable({ + + + @@ -179,6 +187,11 @@ export default function ResultsTable({ }} /> + + {capitalize( + languageNames.of(result?.patient?.preferred_locale ?? 'et'), + )} + ; }) { - const { id: analysisResponseId } = await params; - const analysisResultDetails = await loadResult(Number(analysisResponseId)); + const { id: analysisOrderId } = await params; + const analysisResultDetails = await loadResult(Number(analysisOrderId)); if (!analysisResultDetails) { return null; @@ -28,7 +28,7 @@ async function AnalysisPage({ if (analysisResultDetails) { await createDoctorPageViewLog({ action: DoctorPageViewAction.VIEW_ANALYSIS_RESULTS, - recordKey: analysisResponseId, + recordKey: analysisOrderId, dataOwnerUserId: analysisResultDetails.patient.userId, }); } @@ -50,3 +50,5 @@ async function AnalysisPage({ export default DoctorGuard(AnalysisPage); const loadResult = cache(getAnalysisResultsForDoctor); + + diff --git a/lib/services/account.service.ts b/lib/services/account.service.ts index 2bd7350..22d0684 100644 --- a/lib/services/account.service.ts +++ b/lib/services/account.service.ts @@ -41,3 +41,43 @@ export async function getAccountAdmin({ return data as unknown as AccountWithMemberships; } + +export async function getDoctorAccounts() { + const { data } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('accounts') + .select('id, email, name, last_name, preferred_locale') + .eq('is_personal_account', true) + .eq('application_role', 'doctor') + .throwOnError(); + + return data?.map(({ id, email, name, last_name, preferred_locale }) => ({ + id, + email, + name, + lastName: last_name, + preferredLocale: preferred_locale, + })); +} + +export async function getAssignedDoctorAccount(analysisOrderId: number) { + const { data: doctorUser } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('doctor_analysis_feedback') + .select('doctor_user_id') + .eq('analysis_order_id', analysisOrderId) + .throwOnError(); + + const doctorData = doctorUser[0]; + if (!doctorData || !doctorData.doctor_user_id) { + return null; + } + + const { data } = await getSupabaseServerAdminClient() + .schema('medreport') + .from('accounts') + .select('email') + .eq('primary_owner_user_id', doctorData.doctor_user_id); + + return { email: data?.[0]?.email }; +} diff --git a/lib/services/audit/notificationEntries.service.ts b/lib/services/audit/notificationEntries.service.ts index fee0b75..f83a736 100644 --- a/lib/services/audit/notificationEntries.service.ts +++ b/lib/services/audit/notificationEntries.service.ts @@ -1,8 +1,10 @@ import { Database } from '@kit/supabase/database'; -import { getSupabaseServerClient } from '@kit/supabase/server-client'; +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', } export const createNotificationLog = async ({ @@ -17,7 +19,7 @@ export const createNotificationLog = async ({ relatedRecordId?: string | number; }) => { try { - const supabase = getSupabaseServerClient(); + const supabase = getSupabaseServerAdminClient(); await supabase .schema('audit') @@ -30,6 +32,6 @@ export const createNotificationLog = async ({ }) .throwOnError(); } catch (error) { - console.error('Failed to insert doctor page view log', error); + console.error('Failed to insert doctor notification log', error); } }; diff --git a/lib/services/doctor-jobs.service.ts b/lib/services/doctor-jobs.service.ts new file mode 100644 index 0000000..2f61c00 --- /dev/null +++ b/lib/services/doctor-jobs.service.ts @@ -0,0 +1,32 @@ +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +async function getAssignedOrderIds() { + const supabase = getSupabaseServerAdminClient(); + + const { data: assignedOrderIds } = await supabase + .schema('medreport') + .from('doctor_analysis_feedback') + .select('analysis_order_id') + .not('doctor_user_id', 'is', null) + .throwOnError(); + + return assignedOrderIds?.map((f) => f.analysis_order_id) || []; +} + +export async function getOpenJobAnalysisResponseIds() { + const supabase = getSupabaseServerAdminClient(); + const assignedIds = await getAssignedOrderIds(); + + let query = supabase + .schema('medreport') + .from('analysis_responses') + .select('id, analysis_order_id') + .order('created_at', { ascending: false }); + + if (assignedIds.length > 0) { + query = query.not('analysis_order_id', 'in', `(${assignedIds.join(',')})`); + } + + const { data: analysisResponses } = await query.throwOnError(); + return analysisResponses?.map(({ id }) => id) || []; +} diff --git a/lib/services/mailer.service.ts b/lib/services/mailer.service.ts index c902bad..b4a7ecc 100644 --- a/lib/services/mailer.service.ts +++ b/lib/services/mailer.service.ts @@ -1,50 +1,41 @@ 'use server'; -import { CompanySubmitData } from '@/lib/types/company'; -import { emailSchema } from '@/lib/validations/email.schema'; +import { toArray } from '@/lib/utils'; -import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates'; import { getMailer } from '@kit/mailers'; import { enhanceAction } from '@kit/next/actions'; import { getLogger } from '@kit/shared/logger'; -export const sendDoctorSummaryCompletedEmail = async ( - language: string, - recipientName: string, - recipientEmail: string, - orderNr: string, - orderId: number, -) => { - const { html, subject } = await renderDoctorSummaryReceivedEmail({ - language, - recipientName, - recipientEmail, - orderNr, - orderId, - }); +import { emailSchema } from '~/lib/validations/email.schema'; - await sendEmail({ - subject, - html, - to: recipientEmail, - }); +type EmailTemplate = { + html: string; + subject: string; }; -export const sendCompanyOfferEmail = async ( - data: CompanySubmitData, - language: string, -) => { - const { renderCompanyOfferEmail } = await import('@kit/email-templates'); - const { html, subject } = await renderCompanyOfferEmail({ - language, - companyData: data, - }); +type EmailRenderer = (params: T) => Promise; - await sendEmail({ - subject, - html, - to: process.env.CONTACT_EMAIL || '', - }); +export const sendEmailFromTemplate = async ( + renderer: EmailRenderer, + templateParams: T, + recipients: string | string[], +) => { + const { html, subject } = await renderer(templateParams); + + const recipientsArray = toArray(recipients); + if (!recipientsArray.length) { + throw new Error('No valid email recipients provided'); + } + + const emailPromises = recipientsArray.map((email) => + sendEmail({ + subject, + html, + to: email, + }), + ); + + await Promise.all(emailPromises); }; export const sendEmail = enhanceAction( @@ -53,7 +44,7 @@ export const sendEmail = enhanceAction( const log = await getLogger(); if (!process.env.EMAIL_USER) { - log.error('Sending email failed, as no sender found in env.') + log.error('Sending email failed, as no sender was found in env.'); throw new Error('No email user configured'); } 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 a97d492..5abe3de 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,7 +1,20 @@ 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( @@ -42,6 +55,12 @@ class DatabaseWebhookRouterService { return this.handleAccountsWebhook(payload); } + case 'analysis_orders': { + const payload = body as RecordChange; + + return this.handleAnalysisOrdersWebhook(payload); + } + default: { return; } @@ -83,4 +102,69 @@ class DatabaseWebhookRouterService { return service.handleAccountDeletedWebhook(body.old_record); } } + + private async handleAnalysisOrdersWebhook( + body: RecordChange<'analysis_orders'>, + ) { + if (body.type === 'UPDATE' && body.record && body.old_record) { + const { record, old_record } = body; + + if (record.status === old_record.status) { + return; + } + + let action; + try { + const data = { + analysisOrderId: record.id, + language: 'et', + }; + + if (record.status === 'PARTIAL_ANALYSIS_RESPONSE') { + action = NotificationAction.NEW_JOBS_ALERT; + + 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, + }); + } + } + } } diff --git a/packages/email-templates/src/emails/all-results-received.email.tsx b/packages/email-templates/src/emails/all-results-received.email.tsx new file mode 100644 index 0000000..0243fc4 --- /dev/null +++ b/packages/email-templates/src/emails/all-results-received.email.tsx @@ -0,0 +1,82 @@ +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 { EmailButton } from '../components/email-button'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; +import { initializeEmailI18n } from '../lib/i18n'; + +export async function renderAllResultsReceivedEmail({ + language, + analysisOrderId, +}: { + language: string; + analysisOrderId: number; +}) { + const namespace = 'all-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(`${namespace}:hello`)} + + + {t(`${namespace}:openOrdersHeading`)} + + + {t(`${namespace}:linkText`)} + + + + {t(`${namespace}:ifLinksDisabled`)}{' '} + {`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`} + + + + + + + , + ); + + return { + html, + subject, + }; +} 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 c9e4fae..d091160 100644 --- a/packages/email-templates/src/emails/doctor-summary-received.email.tsx +++ b/packages/email-templates/src/emails/doctor-summary-received.email.tsx @@ -19,14 +19,12 @@ import { initializeEmailI18n } from '../lib/i18n'; export async function renderDoctorSummaryReceivedEmail({ language, - recipientEmail, recipientName, orderNr, orderId, }: { language?: string; recipientName: string; - recipientEmail: string; orderNr: string; orderId: number; }) { @@ -37,8 +35,6 @@ export async function renderDoctorSummaryReceivedEmail({ namespace: [namespace, 'common'], }); - const to = recipientEmail; - const previewText = t(`${namespace}:previewText`, { orderNr, }); @@ -92,6 +88,5 @@ export async function renderDoctorSummaryReceivedEmail({ return { html, subject, - to, }; } diff --git a/packages/email-templates/src/emails/first-results-received.email.tsx b/packages/email-templates/src/emails/first-results-received.email.tsx new file mode 100644 index 0000000..4f9f371 --- /dev/null +++ b/packages/email-templates/src/emails/first-results-received.email.tsx @@ -0,0 +1,86 @@ +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 { EmailButton } from '../components/email-button'; +import { EmailHeader } from '../components/header'; +import { EmailHeading } from '../components/heading'; +import { EmailWrapper } from '../components/wrapper'; +import { initializeEmailI18n } from '../lib/i18n'; + +export async function renderFirstResultsReceivedEmail({ + language, + analysisOrderId, +}: { + language: string; + analysisOrderId: number; +}) { + const namespace = '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(`${namespace}:hello`)} + + + {t(`${namespace}:resultsReceivedForOrders`)} + + + {t(`${namespace}:openOrdersHeading`)} + + + + {t(`${namespace}:linkText`)} + + + + {t(`${namespace}:ifLinksDisabled`)}{' '} + {`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`} + + + + + + + , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/emails/new-jobs-available.email.tsx b/packages/email-templates/src/emails/new-jobs-available.email.tsx new file mode 100644 index 0000000..23ca3f4 --- /dev/null +++ b/packages/email-templates/src/emails/new-jobs-available.email.tsx @@ -0,0 +1,99 @@ +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 renderNewJobsAvailableEmail({ + language, + analysisResponseIds, +}: { + language?: string; + analysisResponseIds: number[]; +}) { + const namespace = 'new-jobs-available-email'; + + const { t } = await initializeEmailI18n({ + language, + namespace: [namespace, 'common'], + }); + + const previewText = t(`${namespace}:previewText`, { + nr: analysisResponseIds.length, + }); + + const subject = t(`${namespace}:subject`, { + nr: analysisResponseIds.length, + }); + + const html = await render( + + + + + + {previewText} + + + + + + {previewText} + + + + + {t(`${namespace}:hello`)} + + + {t(`${namespace}:resultsReceivedForOrders`, { + nr: analysisResponseIds.length, + })} + + + {t(`${namespace}:openOrdersHeading`, { + nr: analysisResponseIds.length, + })} + +
    + {analysisResponseIds.map((analysisResponseId, index) => ( +
  • + + {t(`${namespace}:linkText`, { nr: index + 1 })} + +
  • + ))} +
+ + {t(`${namespace}:ifLinksDisabled`)}{' '} + {`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/open-jobs`} + + +
+
+ +
+ , + ); + + return { + html, + subject, + }; +} diff --git a/packages/email-templates/src/emails/otp.email.tsx b/packages/email-templates/src/emails/otp.email.tsx index ebb6986..ae6db76 100644 --- a/packages/email-templates/src/emails/otp.email.tsx +++ b/packages/email-templates/src/emails/otp.email.tsx @@ -71,7 +71,7 @@ export async function renderOtpEmail(props: Props) {
diff --git a/packages/email-templates/src/emails/synlab.email.tsx b/packages/email-templates/src/emails/synlab.email.tsx index 57f9f36..29ff7d5 100644 --- a/packages/email-templates/src/emails/synlab.email.tsx +++ b/packages/email-templates/src/emails/synlab.email.tsx @@ -9,12 +9,12 @@ import { } 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'; -import CommonFooter from '../components/common-footer'; interface Props { analysisPackageName: string; @@ -31,7 +31,10 @@ export async function renderSynlabAnalysisPackageEmail(props: Props) { namespace: [namespace, 'common'], }); - const previewText = t(`${namespace}:previewText`); + const previewText = t(`${namespace}:previewText`, { + analysisPackageName: props.analysisPackageName, + }); + const subject = t(`${namespace}:subject`, { analysisPackageName: props.analysisPackageName, }); diff --git a/packages/email-templates/src/index.ts b/packages/email-templates/src/index.ts index 8407d1a..83e3021 100644 --- a/packages/email-templates/src/index.ts +++ b/packages/email-templates/src/index.ts @@ -4,3 +4,6 @@ export * from './emails/otp.email'; export * from './emails/company-offer.email'; export * from './emails/synlab.email'; 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'; diff --git a/packages/email-templates/src/locales/en/all-results-received-email.json b/packages/email-templates/src/locales/en/all-results-received-email.json new file mode 100644 index 0000000..c8e7c4b --- /dev/null +++ b/packages/email-templates/src/locales/en/all-results-received-email.json @@ -0,0 +1,8 @@ +{ + "previewText": "All analysis results have been received", + "subject": "All patient analysis results have been received", + "openOrdersHeading": "Review the results and prepare a summary:", + "linkText": "See results", + "ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.", + "hello": "Hello" +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/en/first-results-received-email.json b/packages/email-templates/src/locales/en/first-results-received-email.json new file mode 100644 index 0000000..03693ff --- /dev/null +++ b/packages/email-templates/src/locales/en/first-results-received-email.json @@ -0,0 +1,9 @@ +{ + "previewText": "First analysis responses received", + "subject": "New job - first analysis responses received", + "resultsReceivedForOrders": "New job available to claim", + "openOrdersHeading": "See here:", + "linkText": "See results", + "ifLinksDisabled": "If the link does not work, you can see available jobs by copying this link into your browser.", + "hello": "Hello," +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/en/new-jobs-available-email.json b/packages/email-templates/src/locales/en/new-jobs-available-email.json new file mode 100644 index 0000000..e187b3c --- /dev/null +++ b/packages/email-templates/src/locales/en/new-jobs-available-email.json @@ -0,0 +1,9 @@ +{ + "previewText": "New jobs available", + "subject": "Please write a summary", + "resultsReceivedForOrders": "Please review the results and write a summary.", + "openOrdersHeading": "See here:", + "linkText": "Open job {{nr}}", + "ifLinksDisabled": "If the links do not work, you can see available jobs by copying this link into your browser.", + "hello": "Hello," +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/all-results-received-email.json b/packages/email-templates/src/locales/et/all-results-received-email.json new file mode 100644 index 0000000..a96c137 --- /dev/null +++ b/packages/email-templates/src/locales/et/all-results-received-email.json @@ -0,0 +1,8 @@ +{ + "previewText": "Kõik analüüside vastused on saabunud", + "subject": "Patsiendi kõikide analüüside vastused on saabunud", + "openOrdersHeading": "Vaata tulemusi ja kirjuta kokkuvõte:", + "linkText": "Vaata tulemusi", + "ifLinksDisabled": "Kui link ei tööta, näed analüüsitulemusi sellelt aadressilt:", + "hello": "Tere" +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/first-results-received-email.json b/packages/email-templates/src/locales/et/first-results-received-email.json new file mode 100644 index 0000000..d82aa7d --- /dev/null +++ b/packages/email-templates/src/locales/et/first-results-received-email.json @@ -0,0 +1,9 @@ +{ + "previewText": "Saabusid esimesed analüüside vastused", + "subject": "Uus töö - saabusid esimesed analüüside vastused", + "resultsReceivedForOrders": "Patsiendile saabusid esimesed analüüside vastused.", + "openOrdersHeading": "Vaata siit:", + "linkText": "Vaata tulemusi", + "ifLinksDisabled": "Kui link ei tööta, näed analüüsitulemusi sellelt aadressilt:", + "hello": "Tere" +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/et/new-jobs-available-email.json b/packages/email-templates/src/locales/et/new-jobs-available-email.json new file mode 100644 index 0000000..eae44b8 --- /dev/null +++ b/packages/email-templates/src/locales/et/new-jobs-available-email.json @@ -0,0 +1,9 @@ +{ + "previewText": "Palun koosta kokkuvõte", + "subject": "Palun koosta kokkuvõte", + "resultsReceivedForOrders": "Palun vaata tulemused üle ja kirjuta kokkuvõte.", + "openOrdersHeading": "Vaata siit:", + "linkText": "Töö {{nr}}", + "ifLinksDisabled": "Kui lingid ei tööta, näed vabasid töid sellelt aadressilt:", + "hello": "Tere" +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/ru/all-results-received-email.json b/packages/email-templates/src/locales/ru/all-results-received-email.json new file mode 100644 index 0000000..c8e7c4b --- /dev/null +++ b/packages/email-templates/src/locales/ru/all-results-received-email.json @@ -0,0 +1,8 @@ +{ + "previewText": "All analysis results have been received", + "subject": "All patient analysis results have been received", + "openOrdersHeading": "Review the results and prepare a summary:", + "linkText": "See results", + "ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.", + "hello": "Hello" +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/ru/first-results-received-email.json b/packages/email-templates/src/locales/ru/first-results-received-email.json new file mode 100644 index 0000000..6aff2c7 --- /dev/null +++ b/packages/email-templates/src/locales/ru/first-results-received-email.json @@ -0,0 +1,9 @@ +{ + "previewText": "First analysis responses received", + "subject": "New job - first analysis responses received", + "resultsReceivedForOrders": "New job available to claim", + "openOrdersHeading": "See here:", + "linkText": "See results", + "ifLinksDisabled": "If the link does not work, you can see the results by copying this link into your browser.", + "hello": "Hello," +} \ No newline at end of file diff --git a/packages/email-templates/src/locales/ru/new-jobs-available-email.json b/packages/email-templates/src/locales/ru/new-jobs-available-email.json new file mode 100644 index 0000000..e187b3c --- /dev/null +++ b/packages/email-templates/src/locales/ru/new-jobs-available-email.json @@ -0,0 +1,9 @@ +{ + "previewText": "New jobs available", + "subject": "Please write a summary", + "resultsReceivedForOrders": "Please review the results and write a summary.", + "openOrdersHeading": "See here:", + "linkText": "Open job {{nr}}", + "ifLinksDisabled": "If the links do not work, you can see available jobs by copying this link into your browser.", + "hello": "Hello," +} \ No newline at end of file diff --git a/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema.ts b/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema.ts index 00025c2..439e047 100644 --- a/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema.ts +++ b/packages/features/doctor/src/lib/server/schema/doctor-analysis-detail-view.schema.ts @@ -41,6 +41,7 @@ export const PatientSchema = z.object({ email: z.string().nullable(), height: z.number().optional().nullable(), weight: z.number().optional().nullable(), + preferred_locale: z.string().nullable(), }); export type Patient = z.infer; 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 8758cbb..329d846 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 @@ -80,6 +80,7 @@ export const AccountSchema = z.object({ last_name: z.string().nullable(), id: z.string(), primary_owner_user_id: z.string(), + preferred_locale: z.string().nullable(), }); export type Account = z.infer; diff --git a/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts b/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts index 9bc637a..4f30fd8 100644 --- a/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts +++ b/packages/features/doctor/src/lib/server/services/doctor-analysis.service.ts @@ -2,10 +2,11 @@ import 'server-only'; import { isBefore } from 'date-fns'; +import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates'; import { getFullName } from '@kit/shared/utils'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { sendDoctorSummaryCompletedEmail } from '../../../../../../../lib/services/mailer.service'; +import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service'; import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema'; import { AnalysisResponseBase, @@ -54,7 +55,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) { supabase .schema('medreport') .from('accounts') - .select('name, last_name, id, primary_owner_user_id') + .select('name, last_name, id, primary_owner_user_id, preferred_locale') .in('primary_owner_user_id', userIds), ]); @@ -67,7 +68,7 @@ async function enrichAnalysisData(analysisResponses?: AnalysisResponseBase[]) { ? await supabase .schema('medreport') .from('accounts') - .select('name, last_name, id, primary_owner_user_id') + .select('name, last_name, id, primary_owner_user_id, preferred_locale') .in('primary_owner_user_id', doctorUserIds) : { data: [] }; @@ -408,7 +409,7 @@ export async function getAnalysisResultsForDoctor( .schema('medreport') .from('accounts') .select( - `primary_owner_user_id, id, name, last_name, personal_code, phone, email, + `primary_owner_user_id, id, name, last_name, personal_code, phone, email, preferred_locale, account_params(height,weight)`, ) .eq('is_personal_account', true) @@ -472,6 +473,7 @@ export async function getAnalysisResultsForDoctor( personal_code, phone, account_params, + preferred_locale, } = accountWithParams[0]; const analysisResponseElementsWithPreviousData = []; @@ -503,6 +505,7 @@ export async function getAnalysisResultsForDoctor( }, doctorFeedback: doctorFeedback?.[0], patient: { + preferred_locale, userId: primary_owner_user_id, accountId, firstName: name, @@ -638,7 +641,7 @@ export async function submitFeedback( } if (status === 'COMPLETED') { - const [{ data: recipient }, { data: medusaOrderIds }] = await Promise.all([ + const [{ data: recipient }, { data: analysisOrder }] = await Promise.all([ supabase .schema('medreport') .from('accounts') @@ -659,18 +662,21 @@ export async function submitFeedback( throw new Error('Could not find user email.'); } - if (!medusaOrderIds?.[0]?.id) { + if (!analysisOrder?.[0]?.id) { throw new Error('Could not retrieve order.'); } const { preferred_locale, name, last_name, email } = recipient[0]; - await sendDoctorSummaryCompletedEmail( - preferred_locale ?? 'et', - getFullName(name, last_name), + await sendEmailFromTemplate( + renderDoctorSummaryReceivedEmail, + { + language: preferred_locale ?? 'et', + recipientName: getFullName(name, last_name), + orderNr: analysisOrder?.[0]?.medusa_order_id ?? '', + orderId: analysisOrder[0].id, + }, email, - medusaOrderIds?.[0]?.medusa_order_id ?? '', - medusaOrderIds[0].id, ); } diff --git a/packages/shared/src/hooks/index.ts b/packages/shared/src/hooks/index.ts index f132daf..95e4bfd 100644 --- a/packages/shared/src/hooks/index.ts +++ b/packages/shared/src/hooks/index.ts @@ -1 +1,2 @@ export * from './use-csrf-token'; +export * from './use-current-locale-language-names'; diff --git a/packages/shared/src/hooks/use-current-locale-language-names.ts b/packages/shared/src/hooks/use-current-locale-language-names.ts new file mode 100644 index 0000000..5016e6f --- /dev/null +++ b/packages/shared/src/hooks/use-current-locale-language-names.ts @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; + +import { useTranslation } from 'react-i18next'; + +function useLanguageName(currentLanguage: string) { + return useMemo(() => { + return new Intl.DisplayNames([currentLanguage], { + type: 'language', + }); + }, [currentLanguage]); +} + +export function useCurrentLocaleLanguageNames() { + const { i18n } = useTranslation(); + const { language: currentLanguage } = i18n; + return useLanguageName(currentLanguage); +} diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 2e28960..a7084fe 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -116,5 +116,6 @@ "confirm": "Confirm", "previous": "Previous", "next": "Next", - "invalidDataError": "Invalid data submitted" + "invalidDataError": "Invalid data submitted", + "language": "Language" } \ No newline at end of file diff --git a/public/locales/en/doctor.json b/public/locales/en/doctor.json index 4f6b339..8997f4b 100644 --- a/public/locales/en/doctor.json +++ b/public/locales/en/doctor.json @@ -17,6 +17,7 @@ "assignedTo": "Doctor", "resultsStatus": "Analysis results", "waitingForNr": "Waiting for {{nr}}", + "language": "Preferred language", "responsesReceived": "Results complete" }, "otherPatients": "Other patients", diff --git a/public/locales/et/common.json b/public/locales/et/common.json index 70e6ec6..ed0e02a 100644 --- a/public/locales/et/common.json +++ b/public/locales/et/common.json @@ -136,5 +136,6 @@ "confirm": "Kinnita", "previous": "Eelmine", "next": "Järgmine", - "invalidDataError": "Vigased andmed" + "invalidDataError": "Vigased andmed", + "language": "Keel" } diff --git a/public/locales/et/doctor.json b/public/locales/et/doctor.json index e307ff8..180194b 100644 --- a/public/locales/et/doctor.json +++ b/public/locales/et/doctor.json @@ -17,6 +17,7 @@ "assignedTo": "Arst", "resultsStatus": "Analüüsitulemused", "waitingForNr": "Ootel {{nr}}", + "language": "Patsiendi keel", "responsesReceived": "Tulemused koos" }, "otherPatients": "Muud patsiendid", diff --git a/public/locales/ru/doctor.json b/public/locales/ru/doctor.json index 1d4693b..5ab7fde 100644 --- a/public/locales/ru/doctor.json +++ b/public/locales/ru/doctor.json @@ -13,11 +13,12 @@ "patientName": "Имя пациента", "serviceName": "Услуга", "orderNr": "Номер заказа", - "time": "Сдача пробы", + "time": "Время", "assignedTo": "Врач", "resultsStatus": "Результаты анализов", "waitingForNr": "В ожидании {{nr}}", - "responsesReceived": "Результаты получены" + "language": "Предпочтительный язык", + "responsesReceived": "Результаты готовы" }, "otherPatients": "Другие пациенты", "analyses": "Анализы", diff --git a/supabase/migrations-env-specific/setup_send_unassigned_job_emails_cron.sql b/supabase/migrations-env-specific/setup_send_unassigned_job_emails_cron.sql new file mode 100644 index 0000000..042cf1c --- /dev/null +++ b/supabase/migrations-env-specific/setup_send_unassigned_job_emails_cron.sql @@ -0,0 +1,17 @@ +create extension if not exists pg_cron; +create extension if not exists pg_net; + +select + cron.schedule( + 'send emails with new unassigned jobs 4x a day', + '0 4,9,14,18 * * 1-5', -- Run at 07:00, 12:00, 17:00 and 21:00 (GMT +3) on weekdays only + $$ + select + net.http_post( + url := 'https://test.medreport.ee/api/job/send-open-jobs-emails', + headers := jsonb_build_object( + 'x-jobs-api-key', 'fd26ec26-70ed-11f0-9e95-431ac3b15a84' + ) + ) as request_id; + $$ + ); diff --git a/supabase/migrations/20250828110851_add_service_role_doctor_data_privileges.sql b/supabase/migrations/20250828110851_add_service_role_doctor_data_privileges.sql new file mode 100644 index 0000000..d63a947 --- /dev/null +++ b/supabase/migrations/20250828110851_add_service_role_doctor_data_privileges.sql @@ -0,0 +1,8 @@ +grant select on table "medreport"."doctor_analysis_feedback" to "service_role"; + +create policy "service_role_select" +on "medreport"."doctor_analysis_feedback" +as permissive +for select +to service_role +using (true); \ No newline at end of file diff --git a/supabase/migrations/20250829085942_add_notification_triggers_to_results.sql b/supabase/migrations/20250829085942_add_notification_triggers_to_results.sql new file mode 100644 index 0000000..94de326 --- /dev/null +++ b/supabase/migrations/20250829085942_add_notification_triggers_to_results.sql @@ -0,0 +1,10 @@ + +create trigger "trigger_doctor_notification" after update +on "medreport"."analysis_orders" for each row +execute function "supabase_functions"."http_request"( + 'http://host.docker.internal:3000/api/db/webhook', + 'POST', + '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', + '{}', + '5000' +); \ No newline at end of file diff --git a/supabase/migrations/20250901072953_add_notification_permissions_to_service_role.sql b/supabase/migrations/20250901072953_add_notification_permissions_to_service_role.sql new file mode 100644 index 0000000..fdfcf64 --- /dev/null +++ b/supabase/migrations/20250901072953_add_notification_permissions_to_service_role.sql @@ -0,0 +1,10 @@ +alter table audit.notification_entries enable row level security; + +create policy "service_role_insert" +on "audit"."notification_entries" +as permissive +for insert +to service_role +with check (true); + +grant insert on table "audit"."notification_entries" to "service_role";