MED-82: add patient notification emails (#74)

* MED-82: add patient notification emails

* remove console.log

* clean up

* remove extra paragraph from email
This commit is contained in:
Helena
2025-09-09 10:37:22 +03:00
committed by GitHub
parent d00449da29
commit ca13e9e30a
37 changed files with 718 additions and 179 deletions

View File

@@ -126,7 +126,7 @@ export const giveFeedbackAction = doctorAction(
if (isCompleted) {
await createNotificationLog({
action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED,
action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED,
status: 'SUCCESS',
relatedRecordId: analysisOrderId,
});
@@ -136,7 +136,7 @@ export const giveFeedbackAction = doctorAction(
} catch (e: any) {
if (isCompleted) {
await createNotificationLog({
action: NotificationAction.DOCTOR_FEEDBACK_RECEIVED,
action: NotificationAction.PATIENT_DOCTOR_FEEDBACK_RECEIVED,
status: 'FAIL',
comment: e?.message,
relatedRecordId: analysisOrderId,

View File

@@ -1,9 +1,9 @@
import z from 'zod/v3';
import { Database } from '@kit/supabase/database';
import z from 'zod';
export const doctorJobSelectSchema = z.object({
userId: z.string().uuid(),
userId: z.uuid(),
analysisOrderId: z.number(),
});
export type DoctorJobSelect = z.infer<typeof doctorJobSelectSchema>;

View File

@@ -11,7 +11,8 @@
"exports": {
"./api": "./src/server/api.ts",
"./components": "./src/components/index.ts",
"./hooks": "./src/hooks/index.ts"
"./hooks": "./src/hooks/index.ts",
"./webhooks/*": "./src/server/services/webhooks/*.ts"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",

View File

@@ -0,0 +1,273 @@
import {
renderAllResultsReceivedEmail,
renderFirstResultsReceivedEmail,
renderOrderProcessingEmail,
renderPatientFirstResultsReceivedEmail,
renderPatientFullResultsReceivedEmail,
} from '@kit/email-templates';
import { getLogger } from '@kit/shared/logger';
import { getFullName } from '@kit/shared/utils';
import { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import {
getAssignedDoctorAccount,
getDoctorAccounts,
getUserContactAdmin,
} from '~/lib/services/account.service';
import {
NotificationAction,
createNotificationLog,
} from '~/lib/services/audit/notificationEntries.service';
import {
EmailRenderer,
sendEmailFromTemplate,
} from '~/lib/services/mailer.service';
type AnalysisOrder = Database['medreport']['Tables']['analysis_orders']['Row'];
export function createAnalysisOrderWebhooksService() {
return new AnalysisOrderWebhooksService();
}
class AnalysisOrderWebhooksService {
private readonly namespace = 'analysis_orders.webhooks';
async handleStatusChangeWebhook(analysisOrder: AnalysisOrder) {
const logger = await getLogger();
const ctx = {
analysisOrderId: analysisOrder.id,
namespace: this.namespace,
};
logger.info(ctx, 'Received status change update. Processing...');
let actions: NotificationAction[] = [];
try {
if (analysisOrder.status === 'PROCESSING') {
actions = [NotificationAction.PATIENT_ORDER_PROCESSING];
await this.sendProcessingNotification(analysisOrder);
}
if (analysisOrder.status === 'PARTIAL_ANALYSIS_RESPONSE') {
actions = [
NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED,
NotificationAction.DOCTOR_NEW_JOBS,
];
await this.sendPartialAnalysisResultsNotifications(analysisOrder);
}
if (analysisOrder.status === 'FULL_ANALYSIS_RESPONSE') {
actions = [
NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED,
NotificationAction.PATIENT_FULL_RESULTS_RECEIVED,
];
await this.sendFullAnalysisResultsNotifications(analysisOrder);
}
if (actions.length) {
return logger.info(ctx, 'Status change notifications sent.');
}
logger.info(ctx, 'Status change processed. No notifications to send.');
} catch (e: any) {
if (actions.length)
await Promise.all(
actions.map((action) =>
createNotificationLog({
action,
status: 'FAIL',
comment: e?.message,
relatedRecordId: analysisOrder.id,
}),
),
);
logger.error(
ctx,
`Error while processing status change: ${JSON.stringify(e)}`,
);
}
}
async sendProcessingNotification(analysisOrder: AnalysisOrder) {
const logger = await getLogger();
const supabase = getSupabaseServerAdminClient();
const userContact = await getUserContactAdmin(analysisOrder.user_id);
if (!userContact?.email) {
await createNotificationLog({
action: NotificationAction.PATIENT_ORDER_PROCESSING,
status: 'FAIL',
comment: 'No email found for ' + analysisOrder.user_id,
relatedRecordId: analysisOrder.id,
});
logger.warn(
{ analysisOrderId: analysisOrder.id, namespace: this.namespace },
'No email found ',
);
return;
}
const [{ data: medusaOrder }, { data: analysisElements }] =
await Promise.all([
supabase
.from('order')
.select('id,metadata')
.eq('id', analysisOrder.medusa_order_id)
.single()
.throwOnError(),
supabase
.schema('medreport')
.from('analysis_elements')
.select('materialGroups:material_groups')
.in('id', analysisOrder.analysis_element_ids ?? [])
.throwOnError(),
]);
let isUrine = false;
for (const analysisElement of analysisElements ?? []) {
logger.info({ group: analysisElement.materialGroups ?? [] });
const containsUrineSample = (analysisElement.materialGroups ?? [])?.some(
(element) =>
(element as { Materjal?: { MaterjaliNimi: string } })?.Materjal
?.MaterjaliNimi === 'Uriin',
);
if (containsUrineSample) {
isUrine = true;
break;
}
}
const orderMetadata = medusaOrder.metadata as {
partner_location_name?: string;
};
await sendEmailFromTemplate(
renderOrderProcessingEmail,
{
language: userContact.preferred_locale ?? 'et',
recipientName: getFullName(userContact.name, userContact.last_name),
partnerLocation: orderMetadata.partner_location_name ?? 'SYNLAB',
isUrine,
},
userContact.email,
);
return createNotificationLog({
action: NotificationAction.PATIENT_ORDER_PROCESSING,
status: 'SUCCESS',
relatedRecordId: analysisOrder.id,
});
}
async sendPatientUpdateNotification(
analysisOrder: AnalysisOrder,
template: EmailRenderer,
action: NotificationAction,
) {
const logger = await getLogger();
const userContact = await getUserContactAdmin(analysisOrder.user_id);
if (userContact?.email) {
await sendEmailFromTemplate(
template,
{
analysisOrderId: analysisOrder.id,
recipientName: getFullName(userContact.name, userContact.last_name),
language: userContact.preferred_locale ?? 'et',
},
userContact.email,
);
await createNotificationLog({
action,
status: 'SUCCESS',
relatedRecordId: analysisOrder.id,
});
logger.info(
{ analysisOrderId: analysisOrder.id, namespace: this.namespace },
'Sent notification email',
);
} else {
await createNotificationLog({
action,
status: 'FAIL',
comment: 'No email found for ' + analysisOrder.user_id,
relatedRecordId: analysisOrder.id,
});
logger.warn(
{ analysisOrderId: analysisOrder.id, namespace: this.namespace },
'No email found ',
);
}
}
async sendPartialAnalysisResultsNotifications(analysisOrder: AnalysisOrder) {
const logger = await getLogger();
await this.sendPatientUpdateNotification(
analysisOrder,
renderPatientFirstResultsReceivedEmail,
NotificationAction.PATIENT_FIRST_RESULTS_RECEIVED,
);
const doctorAccounts = await getDoctorAccounts();
const doctorEmails: string[] = doctorAccounts
.map(({ email }) => email)
.filter((email): email is string => !!email);
await sendEmailFromTemplate(
renderFirstResultsReceivedEmail,
{
analysisOrderId: analysisOrder.id,
language: 'et',
},
doctorEmails,
);
logger.info(
{ analysisOrderId: analysisOrder.id, namespace: this.namespace },
'Sent out partial analysis results notifications for doctors',
);
await createNotificationLog({
action: NotificationAction.DOCTOR_NEW_JOBS,
status: 'SUCCESS',
relatedRecordId: analysisOrder.id,
});
}
async sendFullAnalysisResultsNotifications(analysisOrder: AnalysisOrder) {
await this.sendPatientUpdateNotification(
analysisOrder,
renderPatientFullResultsReceivedEmail,
NotificationAction.PATIENT_FULL_RESULTS_RECEIVED,
);
const doctorAccount = await getAssignedDoctorAccount(analysisOrder.id);
const assignedDoctorEmail = doctorAccount?.email;
if (!assignedDoctorEmail) {
return;
}
await sendEmailFromTemplate(
renderAllResultsReceivedEmail,
{
analysisOrderId: analysisOrder.id,
language: 'et',
},
assignedDoctorEmail,
);
return createNotificationLog({
action: NotificationAction.DOCTOR_PATIENT_RESULTS_RECEIVED,
status: 'SUCCESS',
relatedRecordId: analysisOrder.id,
});
}
}