Added reminder emails
This commit is contained in:
@@ -8,6 +8,9 @@ NODE_ENV=development
|
|||||||
TRUST_PROXY=
|
TRUST_PROXY=
|
||||||
ADMIN_EMAILS=corey@blaishome.online
|
ADMIN_EMAILS=corey@blaishome.online
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app
|
RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app
|
||||||
|
DEFAULT_BIRD_PHOTO_PATH=/app/assets/yoda.png
|
||||||
|
MILESTONE_REMINDERS_ENABLED=true
|
||||||
|
MILESTONE_REMINDER_TIME_ZONE=America/New_York
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
STRIPE_PRICE_HOUSEHOLD_CONURE=
|
STRIPE_PRICE_HOUSEHOLD_CONURE=
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ ENV NODE_ENV=production
|
|||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY assets ./assets
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ COPY package*.json ./
|
|||||||
RUN npm install --include=dev
|
RUN npm install --include=dev
|
||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY assets ./assets
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["npm", "run", "dev"]
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
+288
-1
@@ -1,4 +1,6 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import express, { type NextFunction, type Request, type Response } from 'express';
|
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||||
@@ -30,7 +32,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
completePendingBirdTransfersForOwner,
|
completePendingBirdTransfersForOwner,
|
||||||
createBird,
|
createBird,
|
||||||
upsertMedicationAdministrationForBird,
|
createBirdMilestoneReminderDelivery,
|
||||||
createMedicationForBird,
|
createMedicationForBird,
|
||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
findBirdsByBandId,
|
findBirdsByBandId,
|
||||||
@@ -41,6 +43,7 @@ import {
|
|||||||
deleteVetVisitForBird,
|
deleteVetVisitForBird,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
listBirds,
|
listBirds,
|
||||||
|
listDueBirdMilestoneReminders,
|
||||||
listMedicationAdministrationsForBird,
|
listMedicationAdministrationsForBird,
|
||||||
listMedicationsForBird,
|
listMedicationsForBird,
|
||||||
listVetVisitsForBird,
|
listVetVisitsForBird,
|
||||||
@@ -48,6 +51,7 @@ import {
|
|||||||
transferBirdToWorkspace,
|
transferBirdToWorkspace,
|
||||||
updateBird,
|
updateBird,
|
||||||
updateMedicationForBird,
|
updateMedicationForBird,
|
||||||
|
upsertMedicationAdministrationForBird,
|
||||||
updateVetVisitForBird,
|
updateVetVisitForBird,
|
||||||
} from './repositories/birdRepository.js';
|
} from './repositories/birdRepository.js';
|
||||||
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
|
||||||
@@ -81,6 +85,7 @@ import type {
|
|||||||
BillingInterval,
|
BillingInterval,
|
||||||
BillingPlan,
|
BillingPlan,
|
||||||
BirdGender,
|
BirdGender,
|
||||||
|
BirdMilestoneReminderCandidateRow,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
IntegrationTokenRow,
|
IntegrationTokenRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
@@ -114,6 +119,9 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
|
|||||||
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
|
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
|
||||||
const sessionDays = 30;
|
const sessionDays = 30;
|
||||||
const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
|
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) {
|
if (trustProxy) {
|
||||||
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
|
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
|
||||||
@@ -793,6 +801,64 @@ const escapeHtml = (value: string) =>
|
|||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.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 ({
|
const sendRescueStatusNotification = async ({
|
||||||
workspace,
|
workspace,
|
||||||
ownerEmail,
|
ownerEmail,
|
||||||
@@ -1004,6 +1070,206 @@ const sendLostBirdReportNotification = async ({
|
|||||||
return { delivered: true };
|
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) => {
|
const readBearerToken = (authorizationHeader?: string) => {
|
||||||
if (!authorizationHeader) {
|
if (!authorizationHeader) {
|
||||||
return '';
|
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) => {
|
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
|
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
|
||||||
@@ -2368,6 +2654,7 @@ const start = async () => {
|
|||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`FlockPal backend listening on port ${port}`);
|
console.log(`FlockPal backend listening on port ${port}`);
|
||||||
});
|
});
|
||||||
|
startBirdMilestoneReminderScheduler();
|
||||||
};
|
};
|
||||||
|
|
||||||
start().catch((error) => {
|
start().catch((error) => {
|
||||||
|
|||||||
@@ -299,6 +299,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE medications
|
ALTER TABLE medications
|
||||||
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
reminder_type VARCHAR(24) NOT NULL CHECK (reminder_type IN ('hatch_day', 'gotcha_day')),
|
||||||
|
reminder_year INTEGER NOT NULL,
|
||||||
|
delivered_on DATE NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (bird_id, reminder_type, reminder_year)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS medication_administrations (
|
CREATE TABLE IF NOT EXISTS medication_administrations (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
@@ -329,6 +340,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_medications_bird_start_date
|
CREATE INDEX IF NOT EXISTS idx_medications_bird_start_date
|
||||||
ON medications (bird_id, start_date DESC);
|
ON medications (bird_id, start_date DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
|
||||||
|
ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
|
||||||
ON medication_administrations (bird_id, administered_on DESC);
|
ON medication_administrations (bird_id, administered_on DESC);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { db } from '../db/client.js';
|
import { db } from '../db/client.js';
|
||||||
import type {
|
import type {
|
||||||
BirdGender,
|
BirdGender,
|
||||||
|
BirdMilestoneReminderCandidateRow,
|
||||||
|
BirdMilestoneReminderDeliveryRow,
|
||||||
|
BirdMilestoneReminderType,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
LostBirdMatchRow,
|
LostBirdMatchRow,
|
||||||
MedicationAdministrationRow,
|
MedicationAdministrationRow,
|
||||||
@@ -93,6 +96,98 @@ export const findBirdsByBandId = async (tagId: string) => {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listDueBirdMilestoneReminders = async (runDate: string) => {
|
||||||
|
const result = await db.query<BirdMilestoneReminderCandidateRow>(
|
||||||
|
`WITH reminder_context AS (
|
||||||
|
SELECT $1::date AS run_date,
|
||||||
|
EXTRACT(YEAR FROM $1::date)::int AS reminder_year
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
${birdSelectFields},
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
'hatch_day'::text AS reminder_type,
|
||||||
|
birds.date_of_birth::text AS reminder_date,
|
||||||
|
reminder_context.reminder_year
|
||||||
|
FROM birds
|
||||||
|
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||||
|
CROSS JOIN reminder_context
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT weight_grams, recorded_on
|
||||||
|
FROM weight_records
|
||||||
|
WHERE weight_records.bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
WHERE birds.notify_on_dob = TRUE
|
||||||
|
AND birds.date_of_birth IS NOT NULL
|
||||||
|
AND EXTRACT(MONTH FROM birds.date_of_birth) = EXTRACT(MONTH FROM reminder_context.run_date)
|
||||||
|
AND EXTRACT(DAY FROM birds.date_of_birth) = EXTRACT(DAY FROM reminder_context.run_date)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM bird_milestone_reminder_deliveries deliveries
|
||||||
|
WHERE deliveries.bird_id = birds.id
|
||||||
|
AND deliveries.reminder_type = 'hatch_day'
|
||||||
|
AND deliveries.reminder_year = reminder_context.reminder_year
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
${birdSelectFields},
|
||||||
|
workspaces.name AS workspace_name,
|
||||||
|
'gotcha_day'::text AS reminder_type,
|
||||||
|
birds.gotcha_day::text AS reminder_date,
|
||||||
|
reminder_context.reminder_year
|
||||||
|
FROM birds
|
||||||
|
INNER JOIN workspaces ON workspaces.id = birds.workspace_id
|
||||||
|
CROSS JOIN reminder_context
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT weight_grams, recorded_on
|
||||||
|
FROM weight_records
|
||||||
|
WHERE weight_records.bird_id = birds.id
|
||||||
|
ORDER BY recorded_on DESC
|
||||||
|
LIMIT 1
|
||||||
|
) latest ON TRUE
|
||||||
|
WHERE birds.notify_on_gotcha_day = TRUE
|
||||||
|
AND birds.gotcha_day IS NOT NULL
|
||||||
|
AND EXTRACT(MONTH FROM birds.gotcha_day) = EXTRACT(MONTH FROM reminder_context.run_date)
|
||||||
|
AND EXTRACT(DAY FROM birds.gotcha_day) = EXTRACT(DAY FROM reminder_context.run_date)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM bird_milestone_reminder_deliveries deliveries
|
||||||
|
WHERE deliveries.bird_id = birds.id
|
||||||
|
AND deliveries.reminder_type = 'gotcha_day'
|
||||||
|
AND deliveries.reminder_year = reminder_context.reminder_year
|
||||||
|
)
|
||||||
|
ORDER BY workspace_name ASC, name ASC, reminder_type ASC`,
|
||||||
|
[runDate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBirdMilestoneReminderDelivery = async ({
|
||||||
|
birdId,
|
||||||
|
workspaceId,
|
||||||
|
reminderType,
|
||||||
|
reminderYear,
|
||||||
|
deliveredOn,
|
||||||
|
}: {
|
||||||
|
birdId: string;
|
||||||
|
workspaceId: number;
|
||||||
|
reminderType: BirdMilestoneReminderType;
|
||||||
|
reminderYear: number;
|
||||||
|
deliveredOn: string;
|
||||||
|
}) => {
|
||||||
|
const result = await db.query<BirdMilestoneReminderDeliveryRow>(
|
||||||
|
`INSERT INTO bird_milestone_reminder_deliveries (bird_id, workspace_id, reminder_type, reminder_year, delivered_on)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (bird_id, reminder_type, reminder_year) DO NOTHING
|
||||||
|
RETURNING id, bird_id, workspace_id, reminder_type, reminder_year, delivered_on::text, created_at`,
|
||||||
|
[birdId, workspaceId, reminderType, reminderYear, deliveredOn],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows[0] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
export const createBird = async ({
|
export const createBird = async ({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -115,6 +115,25 @@ export type LostBirdMatchRow = BirdRow & {
|
|||||||
workspace_billing_email: string | null;
|
workspace_billing_email: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BirdMilestoneReminderType = 'hatch_day' | 'gotcha_day';
|
||||||
|
|
||||||
|
export type BirdMilestoneReminderCandidateRow = BirdRow & {
|
||||||
|
workspace_name: string;
|
||||||
|
reminder_type: BirdMilestoneReminderType;
|
||||||
|
reminder_date: string;
|
||||||
|
reminder_year: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BirdMilestoneReminderDeliveryRow = {
|
||||||
|
id: string;
|
||||||
|
bird_id: string;
|
||||||
|
workspace_id: number;
|
||||||
|
reminder_type: BirdMilestoneReminderType;
|
||||||
|
reminder_year: number;
|
||||||
|
delivered_on: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PendingBirdTransferRow = {
|
export type PendingBirdTransferRow = {
|
||||||
id: string;
|
id: string;
|
||||||
bird_id: string;
|
bird_id: string;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ services:
|
|||||||
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
|
DEFAULT_BIRD_PHOTO_PATH: ${DEFAULT_BIRD_PHOTO_PATH:-/app/assets/yoda.png}
|
||||||
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ services:
|
|||||||
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
||||||
|
DEFAULT_BIRD_PHOTO_PATH: ${DEFAULT_BIRD_PHOTO_PATH:-/app/assets/yoda.png}
|
||||||
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
||||||
|
|||||||
+8
-23
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import flockPalLandingArt from './assets/flockpal-landing-art.png';
|
import flockPalLandingArt from './assets/flockpal-landing-art.png';
|
||||||
|
import defaultBirdPhoto from './assets/yoda.png';
|
||||||
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
|
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
|
||||||
|
|
||||||
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
|
||||||
@@ -3870,13 +3871,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<button className="bird-card-select" onClick={() => setSelectedBirdId(bird.id)} type="button">
|
<button className="bird-card-select" onClick={() => setSelectedBirdId(bird.id)} type="button">
|
||||||
<div className="bird-card-header">
|
<div className="bird-card-header">
|
||||||
{bird.photoDataUrl ? (
|
<img className="bird-avatar" src={bird.photoDataUrl || defaultBirdPhoto} alt={`${bird.name}`} />
|
||||||
<img className="bird-avatar" src={bird.photoDataUrl} alt={`${bird.name}`} />
|
|
||||||
) : (
|
|
||||||
<div className="bird-avatar placeholder-avatar" aria-hidden="true">
|
|
||||||
{bird.name.slice(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="bird-card-copy">
|
<div className="bird-card-copy">
|
||||||
<span className="bird-card-title">
|
<span className="bird-card-title">
|
||||||
<span>{bird.name}</span>
|
<span>{bird.name}</span>
|
||||||
@@ -4010,13 +4005,7 @@ function App() {
|
|||||||
|
|
||||||
<>
|
<>
|
||||||
<section className="profile-hero">
|
<section className="profile-hero">
|
||||||
{selectedBird.photoDataUrl ? (
|
<img className="profile-photo" src={selectedBird.photoDataUrl || defaultBirdPhoto} alt={`${selectedBird.name}`} />
|
||||||
<img className="profile-photo" src={selectedBird.photoDataUrl} alt={`${selectedBird.name}`} />
|
|
||||||
) : (
|
|
||||||
<div className="profile-photo placeholder-avatar" aria-hidden="true">
|
|
||||||
{selectedBird.name.slice(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="profile-copy">
|
<div className="profile-copy">
|
||||||
<p className="eyebrow">Profile</p>
|
<p className="eyebrow">Profile</p>
|
||||||
<h3 className="profile-title">
|
<h3 className="profile-title">
|
||||||
@@ -4045,7 +4034,7 @@ function App() {
|
|||||||
<strong>{selectedBird.tagId}</strong>
|
<strong>{selectedBird.tagId}</strong>
|
||||||
</article>
|
</article>
|
||||||
<article className="detail-card">
|
<article className="detail-card">
|
||||||
<span>DOB</span>
|
<span>Hatch Day</span>
|
||||||
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
|
<strong>{formatDate(selectedBird.dateOfBirth)}</strong>
|
||||||
</article>
|
</article>
|
||||||
<article className="detail-card">
|
<article className="detail-card">
|
||||||
@@ -5000,7 +4989,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="settings-nested-grid">
|
<div className="settings-nested-grid">
|
||||||
<label>
|
<label>
|
||||||
DOB
|
Hatch Day
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={birdForm.dateOfBirth}
|
value={birdForm.dateOfBirth}
|
||||||
@@ -5016,13 +5005,13 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="toggle-card">
|
<label className="toggle-card">
|
||||||
<span>Notify on DOB</span>
|
<span>Notify on Hatch Day</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={birdForm.notifyOnDob}
|
checked={birdForm.notifyOnDob}
|
||||||
onChange={(event) => setBirdForm({ ...birdForm, notifyOnDob: event.target.checked })}
|
onChange={(event) => setBirdForm({ ...birdForm, notifyOnDob: event.target.checked })}
|
||||||
/>
|
/>
|
||||||
<small className="muted">Send a reminder on this bird's birthday each year.</small>
|
<small className="muted">Send a reminder on this bird's Hatch Day each year.</small>
|
||||||
</label>
|
</label>
|
||||||
<label className="toggle-card">
|
<label className="toggle-card">
|
||||||
<span>Notify on gotcha day</span>
|
<span>Notify on gotcha day</span>
|
||||||
@@ -5074,12 +5063,8 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<div className="crop-preview-overlay" aria-hidden="true" />
|
<div className="crop-preview-overlay" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
) : birdForm.photoDataUrl ? (
|
|
||||||
<img className="profile-photo" src={birdForm.photoDataUrl} alt="Bird preview" />
|
|
||||||
) : (
|
) : (
|
||||||
<div className="profile-photo placeholder-avatar" aria-hidden="true">
|
<img className="profile-photo" src={birdForm.photoDataUrl || defaultBirdPhoto} alt="Bird preview" />
|
||||||
{(birdForm.name || 'B').slice(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="photo-copy">
|
<div className="photo-copy">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Reference in New Issue
Block a user