Added reminder emails

This commit is contained in:
Corey Blais
2026-04-21 15:04:44 -04:00
parent af40b9901f
commit ee09c95f05
13 changed files with 435 additions and 24 deletions
+288 -1
View File
@@ -1,4 +1,6 @@
import crypto from 'crypto';
import { readFileSync } from 'fs';
import path from 'path';
import cors from 'cors';
import dotenv from 'dotenv';
import express, { type NextFunction, type Request, type Response } from 'express';
@@ -30,7 +32,7 @@ import {
import {
completePendingBirdTransfersForOwner,
createBird,
upsertMedicationAdministrationForBird,
createBirdMilestoneReminderDelivery,
createMedicationForBird,
createPendingBirdTransfer,
findBirdsByBandId,
@@ -41,6 +43,7 @@ import {
deleteVetVisitForBird,
getBirdById,
listBirds,
listDueBirdMilestoneReminders,
listMedicationAdministrationsForBird,
listMedicationsForBird,
listVetVisitsForBird,
@@ -48,6 +51,7 @@ import {
transferBirdToWorkspace,
updateBird,
updateMedicationForBird,
upsertMedicationAdministrationForBird,
updateVetVisitForBird,
} from './repositories/birdRepository.js';
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
@@ -81,6 +85,7 @@ import type {
BillingInterval,
BillingPlan,
BirdGender,
BirdMilestoneReminderCandidateRow,
BirdRow,
IntegrationTokenRow,
LostBirdMatchRow,
@@ -114,6 +119,9 @@ 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 milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
if (trustProxy) {
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
@@ -793,6 +801,64 @@ const escapeHtml = (value: string) =>
.replace(/"/g, '"')
.replace(/'/g, ''');
const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const year = parts.find((part) => part.type === 'year')?.value ?? `${date.getUTCFullYear()}`;
const month = parts.find((part) => part.type === 'month')?.value ?? `${date.getUTCMonth() + 1}`.padStart(2, '0');
const day = parts.find((part) => part.type === 'day')?.value ?? `${date.getUTCDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatOrdinal = (value: number) => {
const remainder = value % 100;
if (remainder >= 11 && remainder <= 13) {
return `${value}th`;
}
switch (value % 10) {
case 1:
return `${value}st`;
case 2:
return `${value}nd`;
case 3:
return `${value}rd`;
default:
return `${value}th`;
}
};
const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => {
const sourceDate = reminder.reminder_type === 'hatch_day' ? reminder.date_of_birth : reminder.gotcha_day;
const sourceYear = Number(sourceDate?.slice(0, 4));
return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0;
};
const getFlockPalLogoDataUrl = () =>
'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22%3E%3Cdefs%3E%3ClinearGradient id=%22featherFill%22 x1=%220%25%22 y1=%220%25%22 x2=%22100%25%22 y2=%22100%25%22%3E%3Cstop offset=%220%25%22 stop-color=%22%23cb3a35%22/%3E%3Cstop offset=%2230%25%22 stop-color=%22%23f0b63f%22/%3E%3Cstop offset=%2258%25%22 stop-color=%22%23238a5a%22/%3E%3Cstop offset=%22100%25%22 stop-color=%22%232769b3%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d=%22M50.8 10.4C37.9 10.3 27 18.5 22.7 31.1c-3.1 9.1-2.1 18.5-8.6 24.8c-1.5 1.5-0.2 4 1.9 3.6c8.4-1.5 14.6-6.7 18.6-13.7c1 0.5 2.2 0.8 3.4 0.8c3.5 0 6.5-2.3 7.5-5.4c1.9-0.4 3.7-1.3 5.1-2.7c2-2 3-4.6 3.1-7.2c3.3-5.8 4.9-12.9 1.4-20.2c-0.7-1.3-2-0.7-4.3-0.7Z%22 fill=%22url(%23featherFill)%22/%3E%3Cpath d=%22M18 56c8.5-3.4 14.2-9.8 18.1-17.8M26.9 48.9c6.9-7.2 13.5-14.8 20.3-22.1M31.8 41.2c6.4-1.3 12.1-4.6 16.5-9.4M36.8 33.8c4.9-0.9 9.2-3.4 12.6-7.1%22 fill=%22none%22 stroke=%22%23fff8ef%22 stroke-linecap=%22round%22 stroke-width=%222.6%22/%3E%3Cpath d=%22M18 56c8.5-3.4 14.2-9.8 18.1-17.8%22 fill=%22none%22 stroke=%22%2363562d%22 stroke-linecap=%22round%22 stroke-width=%222.2%22/%3E%3C/svg%3E';
let defaultBirdPhotoDataUrl: string | null = null;
const getDefaultBirdPhotoDataUrl = () => {
if (defaultBirdPhotoDataUrl) {
return defaultBirdPhotoDataUrl;
}
const defaultPhotoPath = process.env.DEFAULT_BIRD_PHOTO_PATH?.trim() || path.join(process.cwd(), 'assets', 'yoda.png');
try {
defaultBirdPhotoDataUrl = `data:image/png;base64,${readFileSync(defaultPhotoPath).toString('base64')}`;
return defaultBirdPhotoDataUrl;
} catch (error) {
console.warn(`Unable to load default bird photo from ${defaultPhotoPath}`, error);
return null;
}
};
const sendRescueStatusNotification = async ({
workspace,
ownerEmail,
@@ -1004,6 +1070,206 @@ const sendLostBirdReportNotification = async ({
return { delivered: true };
};
const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidateRow) => {
const yearCount = getMilestoneYearCount(reminder);
const anniversaryLabel = yearCount > 0 ? formatOrdinal(yearCount) : '';
if (reminder.reminder_type === 'hatch_day') {
return {
subject: `Happy Hatch Day, ${reminder.name}!`,
eyebrow: 'Hatch Day',
headline: `Happy Hatch Day, ${reminder.name}!`,
eventName: 'Hatch Day',
intro:
yearCount > 0
? `From our flock to yours, wishing ${reminder.name} a happy Hatch Day! ${reminder.name} is ${yearCount} year${yearCount === 1 ? '' : 's'} old today.`
: `${reminder.name} has a Hatch Day today.`,
body: 'Cue the favorite snacks, extra head scritches if approved, and one tiny spotlight for the bird of the day.',
milestoneLabel: yearCount > 0 ? `${yearCount} year${yearCount === 1 ? '' : 's'} old` : 'Hatch Day on file',
signoff: 'FlockPal is keeping the milestone warm in the flock record.',
};
}
return {
subject: `It's ${reminder.name}'s Gotcha Day!`,
eyebrow: 'Gotcha Day',
headline: `It's ${reminder.name}'s Gotcha Day!`,
eventName: 'Gotcha Day',
intro:
yearCount > 0
? `From our flock to yours, wishing ${reminder.name} a happy Gotcha Day! ${reminder.name} joined the flock ${yearCount} year${yearCount === 1 ? '' : 's'} ago today.`
: `${reminder.name} has a Gotcha Day today.`,
body: 'A good day to remember the first ride home, the first brave perch, and every little routine built since.',
milestoneLabel: yearCount > 0 ? `${formatOrdinal(yearCount)} Gotcha Day` : 'Gotcha Day on file',
signoff: 'FlockPal saved the date so the flock can celebrate the story.',
};
};
const sendBirdMilestoneReminderNotification = async ({
reminder,
recipients,
}: {
reminder: BirdMilestoneReminderCandidateRow;
recipients: string[];
}) => {
const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
if (!uniqueRecipients.length) {
return { delivered: false };
}
const copy = buildBirdMilestoneReminderCopy(reminder);
const yearCount = getMilestoneYearCount(reminder);
const logoDataUrl = getFlockPalLogoDataUrl();
const birdPhotoSrc = reminder.photo_data_url || getDefaultBirdPhotoDataUrl();
const birdPhotoHtml = birdPhotoSrc
? `<img src="${escapeHtml(birdPhotoSrc)}" 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 milestoneCountLine = `${copy.eyebrow}: ${copy.milestoneLabel}`;
const lines = [
copy.headline,
'',
copy.intro,
copy.body,
'',
`Bird: ${reminder.name}`,
`Species: ${reminder.species}`,
`Flock: ${reminder.workspace_name}`,
milestoneCountLine,
'',
`Open FlockPal: ${frontendBaseUrl}`,
];
if (!mailTransport) {
console.log(`Bird milestone 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'),
html: `
<div style="margin: 0; padding: 28px; background: #f4efe4; font-family: Arial, sans-serif; color: #263331; line-height: 1.6;">
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background: #fffdf7; border: 1px solid #eadfcd; box-shadow: 0 24px 60px rgba(38, 51, 49, 0.16);">
<div style="padding: 24px 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef);">
<img src="${logoDataUrl}" alt="FlockPal" style="display: block; width: 58px; height: 58px;" />
</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; letter-spacing: 0.16em; text-transform: uppercase; color: #238a5a; font-size: 12px; font-weight: 700;">${escapeHtml(copy.eyebrow)}</p>
<h1 style="margin: 0 0 12px; color: #263331; 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 18px; font-size: 16px;">${escapeHtml(copy.body)}</p>
<div style="margin: 22px 0; padding: 18px; border-radius: 20px; background: #fff8ef; border: 1px solid #eadfcd;">
<p style="margin: 0;"><strong>Bird:</strong> ${escapeHtml(reminder.name)}</p>
<p style="margin: 0;"><strong>Species:</strong> ${escapeHtml(reminder.species)}</p>
<p style="margin: 0;"><strong>Flock:</strong> ${escapeHtml(reminder.workspace_name)}</p>
<p style="margin: 0;"><strong>${escapeHtml(copy.eventName)}:</strong> ${escapeHtml(copy.milestoneLabel)}</p>
</div>
<p style="margin: 0 0 18px;">${escapeHtml(copy.signoff)}</p>
<p style="margin: 0;">
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: #238a5a; color: #ffffff; text-decoration: none; font-weight: 700;">Open FlockPal</a>
</p>
</div>
</div>
</div>
`,
});
return { delivered: true };
};
const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
const reminders = await listDueBirdMilestoneReminders(runDate);
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 sendBirdMilestoneReminderNotification({ reminder, recipients });
if (!result.delivered) {
skipped += 1;
continue;
}
const delivery = await createBirdMilestoneReminderDelivery({
birdId: reminder.id,
workspaceId: reminder.workspace_id,
reminderType: reminder.reminder_type,
reminderYear: reminder.reminder_year,
deliveredOn: runDate,
});
if (delivery) {
sent += 1;
} else {
skipped += 1;
}
} catch (error) {
failed += 1;
console.error(`Unable to send ${reminder.reminder_type} reminder for bird ${reminder.id}`, error);
}
}
return {
runDate,
checked: reminders.length,
sent,
skipped,
failed,
};
};
let lastMilestoneReminderRunDate = '';
const startBirdMilestoneReminderScheduler = () => {
if (!milestoneRemindersEnabled) {
console.log('Bird milestone reminders are disabled.');
return;
}
const runIfNeeded = async () => {
const runDate = getDateInTimeZone();
if (lastMilestoneReminderRunDate === runDate) {
return;
}
lastMilestoneReminderRunDate = runDate;
const result = await runBirdMilestoneReminders(runDate);
console.log(
`Bird milestone reminders completed for ${result.runDate}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
);
};
setTimeout(() => {
void runIfNeeded().catch((error) => {
lastMilestoneReminderRunDate = '';
console.error('Bird milestone reminder scheduler failed', error);
});
}, 15_000);
setInterval(() => {
void runIfNeeded().catch((error) => {
lastMilestoneReminderRunDate = '';
console.error('Bird milestone reminder scheduler failed', error);
});
}, milestoneReminderCheckIntervalMs);
};
const readBearerToken = (authorizationHeader?: string) => {
if (!authorizationHeader) {
return '';
@@ -2358,6 +2624,26 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require
}
});
app.post('/api/admin/reminders/bird-milestones/run', requireAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = z
.object({
runDate: dateStringSchema.optional(),
})
.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({ error: 'Invalid reminder run payload', details: parsed.error.flatten() });
return;
}
try {
const result = await runBirdMilestoneReminders(parsed.data.runDate ?? getDateInTimeZone());
res.json(result);
} catch (error) {
next(error);
}
});
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
@@ -2368,6 +2654,7 @@ const start = async () => {
app.listen(port, () => {
console.log(`FlockPal backend listening on port ${port}`);
});
startBirdMilestoneReminderScheduler();
};
start().catch((error) => {