MED-88: add doctor email notifications (#65)

* MED-88: add doctor email notifications

* add logging, send open jobs notification on partial analysis response

* update permissions

* fix import, permissions

* casing, let email be null

* unused import
This commit is contained in:
Helena
2025-09-02 12:14:01 +03:00
committed by GitHub
parent 56a832b96b
commit 3498406a0c
41 changed files with 751 additions and 69 deletions

View File

@@ -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<typeof body.table>;
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,
});
}
}
}
}

View File

@@ -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(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
</Text>
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
{t(`${namespace}:openOrdersHeading`)}
</Text>
<EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
>
{t(`${namespace}:linkText`)}
</EmailButton>
<Text>
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -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,
};
}

View File

@@ -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(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:resultsReceivedForOrders`)}
</Text>
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
{t(`${namespace}:openOrdersHeading`)}
</Text>
<EmailButton
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
>
{t(`${namespace}:linkText`)}
</EmailButton>
<Text>
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisOrderId}`}
</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -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(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{previewText}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:resultsReceivedForOrders`, {
nr: analysisResponseIds.length,
})}
</Text>
<Text className="text-[16px] leading-[24px] font-semibold text-[#242424]">
{t(`${namespace}:openOrdersHeading`, {
nr: analysisResponseIds.length,
})}
</Text>
<ul className="list-none text-[16px] leading-[24px]">
{analysisResponseIds.map((analysisResponseId, index) => (
<li>
<Link
key={analysisResponseId}
href={`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/analysis/${analysisResponseId}`}
>
{t(`${namespace}:linkText`, { nr: index + 1 })}
</Link>
</li>
))}
</ul>
<Text>
{t(`${namespace}:ifLinksDisabled`)}{' '}
{`${process.env.NEXT_PUBLIC_SITE_URL}/doctor/open-jobs`}
</Text>
<CommonFooter t={t} />
</EmailContent>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -71,7 +71,7 @@ export async function renderOtpEmail(props: Props) {
<Section className="mb-[16px] mt-[16px] text-center">
<Button className={'w-full rounded bg-neutral-950 text-center'}>
<Text className="text-[16px] font-medium font-semibold leading-[16px] text-white">
<Text className="text-[16px] font-semibold leading-[16px] text-white">
{props.otp}
</Text>
</Button>

View File

@@ -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,
});

View File

@@ -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';

View File

@@ -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"
}

View File

@@ -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,"
}

View File

@@ -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,"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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,"
}

View File

@@ -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,"
}

View File

@@ -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<typeof PatientSchema>;

View File

@@ -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<typeof AccountSchema>;

View File

@@ -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,
);
}

View File

@@ -1 +1,2 @@
export * from './use-csrf-token';
export * from './use-current-locale-language-names';

View File

@@ -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);
}