Medication reminder and pdr worker
This commit is contained in:
+249
-3
@@ -13,8 +13,9 @@ import Stripe from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ensureSchema } from './db/schema.js';
|
||||
import { enqueueAdoptionReportJob, adoptionReportQueueEvents } from './queues/adoptionReportQueue.js';
|
||||
import { adoptionReportQueueEvents, enqueueAdoptionReportJob } from './queues/adoptionReportQueue.js';
|
||||
import { enqueueBirdMilestoneReminderJob, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
||||
import { enqueueMedicationReminderJob, getMedicationReminderQueueCounts } from './queues/medicationReminderQueue.js';
|
||||
import {
|
||||
consumeMagicLinkToken,
|
||||
consumeOAuthState,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
completePendingBirdTransfersForOwner,
|
||||
createBird,
|
||||
createBirdMilestoneReminderDelivery,
|
||||
createMedicationReminderDelivery,
|
||||
createBirdTransferCode,
|
||||
createMedicationForBird,
|
||||
createPendingBirdTransfer,
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
getOpenBirdTransferCodeForBird,
|
||||
listBirds,
|
||||
listDueBirdMilestoneReminders,
|
||||
listDueMedicationReminders,
|
||||
listMemorializedBirds,
|
||||
listMedicationAdministrationsForBird,
|
||||
listMedicationsForBird,
|
||||
@@ -118,8 +121,9 @@ import type {
|
||||
FlockNoteRow,
|
||||
IntegrationTokenRow,
|
||||
LostBirdMatchRow,
|
||||
MedicationRow,
|
||||
MedicationAdministrationRow,
|
||||
MedicationReminderCandidateRow,
|
||||
MedicationRow,
|
||||
ProviderKey,
|
||||
RescueVerificationStatus,
|
||||
SubscriptionStatus,
|
||||
@@ -148,10 +152,11 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
|
||||
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
|
||||
const sessionDays = 30;
|
||||
const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
||||
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
|
||||
const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||
const medicationRemindersEnabled = (process.env.MEDICATION_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
|
||||
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
|
||||
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
|
||||
const adoptionReportRenderTimeoutMs = Number(process.env.ADOPTION_REPORT_RENDER_TIMEOUT_MS ?? 45_000);
|
||||
const photoDeliveryMode = process.env.PHOTO_DELIVERY_MODE === 'redirect' ? 'redirect' : 'proxy';
|
||||
|
||||
if (trustProxy) {
|
||||
@@ -325,6 +330,7 @@ const medicationSchema = z
|
||||
startDate: dateStringSchema,
|
||||
endDate: dateStringSchema.optional().or(z.literal('')),
|
||||
notes: z.string().trim().max(1000).optional().or(z.literal('')),
|
||||
remindersEnabled: z.boolean().optional(),
|
||||
})
|
||||
.refine((value) => !value.endDate || value.endDate >= value.startDate, {
|
||||
message: 'End date must be on or after start date.',
|
||||
@@ -720,6 +726,7 @@ const normalizeMedication = (row: MedicationRow) => ({
|
||||
startDate: row.start_date,
|
||||
endDate: row.end_date,
|
||||
notes: row.notes,
|
||||
remindersEnabled: row.reminders_enabled,
|
||||
});
|
||||
|
||||
const normalizeMedicationAdministration = (row: MedicationAdministrationRow) => ({
|
||||
@@ -1218,6 +1225,18 @@ const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZo
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const getTimeInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const hour = parts.find((part) => part.type === 'hour')?.value ?? `${date.getUTCHours()}`.padStart(2, '0');
|
||||
const minute = parts.find((part) => part.type === 'minute')?.value ?? `${date.getUTCMinutes()}`.padStart(2, '0');
|
||||
return `${hour === '24' ? '00' : hour}:${minute}`;
|
||||
};
|
||||
|
||||
const formatOrdinal = (value: number) => {
|
||||
const remainder = value % 100;
|
||||
if (remainder >= 11 && remainder <= 13) {
|
||||
@@ -1725,6 +1744,33 @@ const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidate
|
||||
};
|
||||
};
|
||||
|
||||
const formatReminderDoseTime = (time: string) => {
|
||||
const [rawHour, rawMinute] = time.split(':');
|
||||
const hour = Number(rawHour);
|
||||
const minute = rawMinute ?? '00';
|
||||
if (Number.isNaN(hour)) {
|
||||
return time;
|
||||
}
|
||||
const period = hour >= 12 ? 'PM' : 'AM';
|
||||
const displayHour = hour % 12 || 12;
|
||||
return `${displayHour}:${minute} ${period}`;
|
||||
};
|
||||
|
||||
const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) => {
|
||||
const doseTime = formatReminderDoseTime(reminder.administration_time);
|
||||
const slotLabel = reminder.administration_label || 'Dose';
|
||||
const route = reminder.route ? ` by ${reminder.route}` : '';
|
||||
|
||||
return {
|
||||
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
|
||||
eyebrow: 'Medication Reminder',
|
||||
headline: `${slotLabel} time for ${reminder.name}`,
|
||||
intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
|
||||
body: `Dose: ${reminder.dosage}${route}.`,
|
||||
detailLabel: `${slotLabel} at ${doseTime}`,
|
||||
};
|
||||
};
|
||||
|
||||
const sendBirdMilestoneReminderNotification = async ({
|
||||
reminder,
|
||||
recipients,
|
||||
@@ -1831,6 +1877,120 @@ const sendBirdMilestoneReminderNotification = async ({
|
||||
return { delivered: true };
|
||||
};
|
||||
|
||||
const sendMedicationReminderNotification = async ({
|
||||
reminder,
|
||||
recipients,
|
||||
}: {
|
||||
reminder: MedicationReminderCandidateRow;
|
||||
recipients: string[];
|
||||
}) => {
|
||||
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
|
||||
|
||||
if (!uniqueRecipients.length) {
|
||||
return { delivered: false };
|
||||
}
|
||||
|
||||
const copy = buildMedicationReminderCopy(reminder);
|
||||
const attachments: NonNullable<SendMailOptions['attachments']> = [];
|
||||
const logoAttachment = getFlockPalLogoAttachment();
|
||||
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
|
||||
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
|
||||
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
|
||||
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
|
||||
|
||||
if (logoAttachment) {
|
||||
attachments.push(logoAttachment);
|
||||
}
|
||||
|
||||
if (uploadedBirdPhoto) {
|
||||
attachments.push({
|
||||
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
|
||||
content: uploadedBirdPhoto.content,
|
||||
contentType: uploadedBirdPhoto.contentType,
|
||||
cid: birdPhotoCid,
|
||||
contentDisposition: 'inline',
|
||||
});
|
||||
} else if (defaultBirdPhoto) {
|
||||
attachments.push(defaultBirdPhoto);
|
||||
}
|
||||
|
||||
const birdPhotoHtml = birdPhotoCid
|
||||
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
|
||||
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
|
||||
const medicationNotesHtml = reminder.medication_notes
|
||||
? `<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>`
|
||||
: '';
|
||||
const lines = [
|
||||
copy.headline,
|
||||
'',
|
||||
copy.intro,
|
||||
copy.body,
|
||||
'',
|
||||
`Bird: ${reminder.name}`,
|
||||
`Medication: ${reminder.medication_name}`,
|
||||
`Dose: ${reminder.dosage}`,
|
||||
`Scheduled dose: ${copy.detailLabel}`,
|
||||
reminder.medication_notes ? `Notes: ${reminder.medication_notes}` : '',
|
||||
'',
|
||||
`Open FlockPal: ${frontendBaseUrl}`,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!mailTransport) {
|
||||
console.log(`Medication reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
|
||||
return { delivered: false };
|
||||
}
|
||||
|
||||
await mailTransport.sendMail({
|
||||
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
|
||||
to: smtpFromEmail,
|
||||
bcc: uniqueRecipients,
|
||||
subject: copy.subject,
|
||||
text: lines.join('\n'),
|
||||
attachments,
|
||||
html: `
|
||||
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
|
||||
<div style="max-width: 680px; margin: 0 auto 18px;">
|
||||
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||
</div>
|
||||
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
|
||||
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
|
||||
${
|
||||
logoAttachment
|
||||
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
|
||||
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
|
||||
}
|
||||
</div>
|
||||
<div style="padding: 30px 28px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
|
||||
${birdPhotoHtml}
|
||||
</td>
|
||||
<td style="vertical-align: top; padding: 0 0 20px;">
|
||||
<p style="margin: 0 0 8px; color: #238a5a; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;">${escapeHtml(copy.eyebrow)}</p>
|
||||
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
|
||||
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 4px 0 10px; font-size: 16px;">${escapeHtml(copy.body)}</p>
|
||||
<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Schedule:</strong> ${escapeHtml(copy.detailLabel)}</p>
|
||||
${medicationNotesHtml}
|
||||
<p style="margin: 0;">
|
||||
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-width: 680px; margin: 18px auto 0;">
|
||||
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
return { delivered: true };
|
||||
};
|
||||
|
||||
export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
|
||||
const reminders = await listDueBirdMilestoneReminders(runDate);
|
||||
let sent = 0;
|
||||
@@ -1875,7 +2035,53 @@ export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) =
|
||||
};
|
||||
};
|
||||
|
||||
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
|
||||
const reminders = await listDueMedicationReminders(runDate, currentTime);
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const reminder of reminders) {
|
||||
try {
|
||||
const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id);
|
||||
const result = await sendMedicationReminderNotification({ reminder, recipients });
|
||||
|
||||
if (!result.delivered) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const delivery = await createMedicationReminderDelivery({
|
||||
medicationId: reminder.medication_id,
|
||||
birdId: reminder.id,
|
||||
workspaceId: reminder.workspace_id,
|
||||
scheduledOn: runDate,
|
||||
administrationSlot: reminder.administration_slot,
|
||||
});
|
||||
|
||||
if (delivery) {
|
||||
sent += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
failed += 1;
|
||||
console.error(`Unable to send medication reminder for medication ${reminder.medication_id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runDate,
|
||||
currentTime,
|
||||
checked: reminders.length,
|
||||
sent,
|
||||
skipped,
|
||||
failed,
|
||||
};
|
||||
};
|
||||
|
||||
let lastMilestoneReminderRunDate = '';
|
||||
let lastMedicationReminderRunKey = '';
|
||||
|
||||
export const startBirdMilestoneReminderScheduler = () => {
|
||||
if (!milestoneRemindersEnabled) {
|
||||
@@ -1909,6 +2115,42 @@ export const startBirdMilestoneReminderScheduler = () => {
|
||||
}, milestoneReminderCheckIntervalMs);
|
||||
};
|
||||
|
||||
export const startMedicationReminderScheduler = () => {
|
||||
if (!medicationRemindersEnabled) {
|
||||
console.log('Medication reminders are disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const runIfNeeded = async () => {
|
||||
const now = new Date();
|
||||
const runDate = getDateInTimeZone(now);
|
||||
const currentTime = getTimeInTimeZone(now);
|
||||
const runKey = `${runDate}-${currentTime.slice(0, 2)}`;
|
||||
|
||||
if (lastMedicationReminderRunKey === runKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastMedicationReminderRunKey = runKey;
|
||||
const job = await enqueueMedicationReminderJob(runDate, currentTime);
|
||||
console.log(`Medication reminder job queued for ${runDate} ${currentTime}: id=${job.id ?? 'unknown'}`);
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
void runIfNeeded().catch((error) => {
|
||||
lastMedicationReminderRunKey = '';
|
||||
console.error('Medication reminder scheduler failed', error);
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
setInterval(() => {
|
||||
void runIfNeeded().catch((error) => {
|
||||
lastMedicationReminderRunKey = '';
|
||||
console.error('Medication reminder scheduler failed', error);
|
||||
});
|
||||
}, milestoneReminderCheckIntervalMs);
|
||||
};
|
||||
|
||||
const readBearerToken = (authorizationHeader?: string) => {
|
||||
if (!authorizationHeader) {
|
||||
return '';
|
||||
@@ -2060,6 +2302,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
||||
|
||||
try {
|
||||
const birdMilestoneReminderQueueCounts = await getBirdMilestoneReminderQueueCounts();
|
||||
const medicationReminderQueueCounts = await getMedicationReminderQueueCounts();
|
||||
|
||||
res.json({
|
||||
startedAt: requestMetrics.startedAt,
|
||||
@@ -2081,6 +2324,7 @@ app.get('/api/metrics', requireAuth, requireAdmin, async (_req: Request, res: Re
|
||||
},
|
||||
queues: {
|
||||
birdMilestoneReminders: birdMilestoneReminderQueueCounts,
|
||||
medicationReminders: medicationReminderQueueCounts,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -3844,6 +4088,7 @@ app.post('/api/birds/:birdId/medications', requireAuth, requireWriteAccess, requ
|
||||
parsed.data.startDate,
|
||||
emptyToNull(parsed.data.endDate),
|
||||
emptyToNull(parsed.data.notes),
|
||||
parsed.data.remindersEnabled ?? false,
|
||||
);
|
||||
|
||||
await writeAuditLog(req.auth!, 'medication.created', 'medication', medication!.id, medication!.name, {
|
||||
@@ -3887,6 +4132,7 @@ app.put('/api/birds/:birdId/medications/:medicationId', requireAuth, requireWrit
|
||||
parsed.data.startDate,
|
||||
emptyToNull(parsed.data.endDate),
|
||||
emptyToNull(parsed.data.notes),
|
||||
parsed.data.remindersEnabled ?? false,
|
||||
);
|
||||
|
||||
if (!medication) {
|
||||
|
||||
Reference in New Issue
Block a user