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";