changed weight reminder schedule
Deploy / deploy-dev (push) Successful in 2m32s
Deploy / deploy-prod (push) Skipped

This commit is contained in:
blaisadmin
2026-09-05 23:23:56 -04:00
parent 5abc7f6174
commit 856753b012
9 changed files with 84 additions and 25 deletions
+1 -1
View File
@@ -40,5 +40,5 @@ STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
# Send an email after 48 hours without a new weight entry. Requires SMTP and worker. # Daily email for birds without a new weight entry in 48 hours; uses the milestone time zone.
WEIGHT_REMINDERS_ENABLED=true WEIGHT_REMINDERS_ENABLED=true
+8 -2
View File
@@ -163,12 +163,18 @@ npm run worker
### Overdue weight emails ### Overdue weight emails
The worker checks every five minutes for living birds with no new weight entry in The worker checks once per calendar day for living birds with no new weight entry in
48 hours. Each accepted owner, assistant, or caregiver receives one email per flock 48 hours. Each accepted owner, assistant, or caregiver receives one email per flock
listing all birds due for a reminder. Pending invitees and viewers are excluded. listing all birds due for a reminder. Pending invitees and viewers are excluded.
Reminders repeat every 48 hours while overdue; adding a new weight restarts the Reminders repeat daily while overdue; adding a new weight restarts the
48-hour clock. Birds without weights use their profile creation time. 48-hour clock. Birds without weights use their profile creation time.
Scheduling matches hatch-day reminders: a check 15 seconds after worker startup,
then hourly, with one queued job per local date. There is no fixed morning send
time. Both use `MILESTONE_REMINDER_TIME_ZONE` (default `America/New_York`).
Delivery tracking uses local calendar dates, including daylight-saving changes.
Deploying this change removes the old five-minute Redis schedule.
The clock uses the weight entry's creation timestamp, since the measurement date The clock uses the weight entry's creation timestamp, since the measurement date
has no time of day. Editing an existing entry does not restart the clock; entering has no time of day. Editing an existing entry does not restart the clock; entering
a historical weight does. Memorialized birds are excluded. a historical weight does. Memorialized birds are excluded.
+24 -8
View File
@@ -1,4 +1,5 @@
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { getReminderDate } from '../reminders/reminderDate.js';
import { redisConnection } from './redisConnection.js'; import { redisConnection } from './redisConnection.js';
export const weightReminderQueueName = 'weight-reminders'; export const weightReminderQueueName = 'weight-reminders';
@@ -12,15 +13,30 @@ export const weightReminderQueue = new Queue(weightReminderQueueName, {
}, },
}); });
export const enqueueDailyWeightReminder = (now = new Date()) => {
const runDate = getReminderDate(now);
return weightReminderQueue.add('check-overdue-weights', { runDate }, {
jobId: `weight-reminders-${runDate}`,
});
};
export const startWeightReminderScheduler = async () => { export const startWeightReminderScheduler = async () => {
if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') { // Remove the previous five-minute schedule during upgrades and when disabled.
await weightReminderQueue.removeJobScheduler('weight-reminders'); await weightReminderQueue.removeJobScheduler('weight-reminders');
return; if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') return () => {};
}
await weightReminderQueue.setGlobalConcurrency(1); await weightReminderQueue.setGlobalConcurrency(1);
await weightReminderQueue.upsertJobScheduler( let lastRunDate = '';
'weight-reminders', const runIfNeeded = async () => {
{ every: 5 * 60_000 }, const now = new Date();
{ name: 'check-overdue-weights', data: {} }, const runDate = getReminderDate(now);
); if (lastRunDate === runDate) return;
await enqueueDailyWeightReminder(now);
lastRunDate = runDate;
};
const check = () => {
void runIfNeeded().catch(error => console.error('Weight reminder scheduler failed', error));
};
const startup = setTimeout(check, 15_000);
const interval = setInterval(check, 60 * 60_000);
return () => { clearTimeout(startup); clearInterval(interval); };
}; };
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getReminderDate } from './reminderDate.js';
test('daily reminder dates use the configured zone across midnight and DST', () => {
const previous = process.env.MILESTONE_REMINDER_TIME_ZONE;
try {
process.env.MILESTONE_REMINDER_TIME_ZONE = 'America/New_York';
assert.equal(getReminderDate(new Date('2026-09-06T03:59:59Z')), '2026-09-05');
assert.equal(getReminderDate(new Date('2026-09-06T04:00:00Z')), '2026-09-06');
assert.equal(getReminderDate(new Date('2026-03-08T06:59:59Z')), '2026-03-08');
assert.equal(getReminderDate(new Date('2026-03-08T07:00:00Z')), '2026-03-08');
assert.equal(getReminderDate(new Date('2026-11-01T05:30:00Z')), '2026-11-01');
assert.equal(getReminderDate(new Date('2026-11-01T06:30:00Z')), '2026-11-01');
process.env.MILESTONE_REMINDER_TIME_ZONE = 'UTC';
assert.equal(getReminderDate(new Date('2026-09-06T03:59:59Z')), '2026-09-06');
} finally {
if (previous === undefined) delete process.env.MILESTONE_REMINDER_TIME_ZONE;
else process.env.MILESTONE_REMINDER_TIME_ZONE = previous;
}
});
+5
View File
@@ -0,0 +1,5 @@
export const getReminderTimeZone = () => process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
export const getReminderDate = (now = new Date()) => new Intl.DateTimeFormat('en-CA', {
timeZone: getReminderTimeZone(), year: 'numeric', month: '2-digit', day: '2-digit',
}).format(now);
+5 -4
View File
@@ -1,3 +1,4 @@
import { getReminderTimeZone } from './reminderDate.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { db } from '../db/client.js'; import { db } from '../db/client.js';
@@ -40,11 +41,11 @@ export const listDueWeightReminders = async () => {
SELECT 1 FROM weight_reminder_deliveries d SELECT 1 FROM weight_reminder_deliveries d
WHERE d.bird_id = activity.bird_id AND d.workspace_id = activity.workspace_id WHERE d.bird_id = activity.bird_id AND d.workspace_id = activity.workspace_id
AND d.activity_at = activity.activity_at AND d.recipient = recipients.recipient AND d.activity_at = activity.activity_at AND d.recipient = recipients.recipient
AND (d.delivered_at > CURRENT_TIMESTAMP - INTERVAL '48 hours' AND ((d.delivered_at AT TIME ZONE $1)::date >= (CURRENT_TIMESTAMP AT TIME ZONE $1)::date
OR (d.delivered_at IS NULL AND d.claimed_at > CURRENT_TIMESTAMP - INTERVAL '15 minutes')) OR (d.delivered_at IS NULL AND d.claimed_at > CURRENT_TIMESTAMP - INTERVAL '15 minutes'))
) )
ORDER BY activity.workspace_id, activity.bird_id, recipients.recipient ORDER BY activity.workspace_id, activity.bird_id, recipients.recipient
`); `, [getReminderTimeZone()]);
return result.rows; return result.rows;
}; };
@@ -59,11 +60,11 @@ export const claimWeightReminder = async (reminder: WeightReminder, token: strin
) )
ON CONFLICT (bird_id, workspace_id, activity_at, recipient) DO UPDATE ON CONFLICT (bird_id, workspace_id, activity_at, recipient) DO UPDATE
SET claim_token = EXCLUDED.claim_token, claimed_at = CURRENT_TIMESTAMP, delivered_at = NULL SET claim_token = EXCLUDED.claim_token, claimed_at = CURRENT_TIMESTAMP, delivered_at = NULL
WHERE weight_reminder_deliveries.delivered_at <= CURRENT_TIMESTAMP - INTERVAL '48 hours' WHERE (weight_reminder_deliveries.delivered_at AT TIME ZONE $6)::date < (CURRENT_TIMESTAMP AT TIME ZONE $6)::date
OR (weight_reminder_deliveries.delivered_at IS NULL OR (weight_reminder_deliveries.delivered_at IS NULL
AND weight_reminder_deliveries.claimed_at <= CURRENT_TIMESTAMP - INTERVAL '15 minutes') AND weight_reminder_deliveries.claimed_at <= CURRENT_TIMESTAMP - INTERVAL '15 minutes')
RETURNING bird_id RETURNING bird_id
`, [reminder.bird_id, reminder.workspace_id, reminder.activity_at, reminder.recipient, token]); `, [reminder.bird_id, reminder.workspace_id, reminder.activity_at, reminder.recipient, token, getReminderTimeZone()]);
return Boolean(result.rowCount); return Boolean(result.rowCount);
}; };
+3 -1
View File
@@ -32,6 +32,7 @@ import {
import { redisConnection } from './queues/redisConnection.js'; import { redisConnection } from './queues/redisConnection.js';
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js'; import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
let stopWeightReminderScheduler: (() => void) | undefined;
let weightReminderWorker: Worker | null = null; let weightReminderWorker: Worker | null = null;
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null; let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null; let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
@@ -106,7 +107,7 @@ const startWorker = async () => {
weightReminderWorker.on('failed', (job, error) => { weightReminderWorker.on('failed', (job, error) => {
console.error(`Weight reminder job failed: id=${job?.id ?? 'unknown'}`, error); console.error(`Weight reminder job failed: id=${job?.id ?? 'unknown'}`, error);
}); });
await startWeightReminderScheduler(); stopWeightReminderScheduler = await startWeightReminderScheduler();
startBirdMilestoneReminderScheduler(); startBirdMilestoneReminderScheduler();
startMedicationReminderScheduler(); startMedicationReminderScheduler();
console.log('FlockPal worker started.'); console.log('FlockPal worker started.');
@@ -114,6 +115,7 @@ const startWorker = async () => {
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
console.log(`FlockPal worker received ${signal}; shutting down.`); console.log(`FlockPal worker received ${signal}; shutting down.`);
stopWeightReminderScheduler?.();
await weightReminderWorker?.close(); await weightReminderWorker?.close();
await weightReminderQueue.close(); await weightReminderQueue.close();
await birdMilestoneWorker?.close(); await birdMilestoneWorker?.close();
+14 -6
View File
@@ -14,11 +14,13 @@ Object.assign(process.env, {
SMTP_USER: '', SMTP_PASS: '', SMTP_FROM_EMAIL: 'reminders@flockpal.test', SMTP_FROM_NAME: 'FlockPal Test', SMTP_USER: '', SMTP_PASS: '', SMTP_FROM_EMAIL: 'reminders@flockpal.test', SMTP_FROM_NAME: 'FlockPal Test',
FRONTEND_URL: 'http://flockpal.test', MILESTONE_REMINDERS_ENABLED: 'false', FRONTEND_URL: 'http://flockpal.test', MILESTONE_REMINDERS_ENABLED: 'false',
MEDICATION_REMINDERS_ENABLED: 'false', WEIGHT_REMINDERS_ENABLED: 'true', MEDICATION_REMINDERS_ENABLED: 'false', WEIGHT_REMINDERS_ENABLED: 'true',
MILESTONE_REMINDER_TIME_ZONE: 'America/New_York',
}); });
const { db } = await import('../dist/db/client.js'); const { db } = await import('../dist/db/client.js');
const { ensureSchema } = await import('../dist/db/schema.js'); const { ensureSchema } = await import('../dist/db/schema.js');
const { getReminderDate } = await import('../dist/reminders/reminderDate.js');
const { listDueWeightReminders, claimWeightReminder, finishWeightReminder } = await import('../dist/reminders/weightReminders.js'); const { listDueWeightReminders, claimWeightReminder, finishWeightReminder } = await import('../dist/reminders/weightReminders.js');
const { weightReminderQueue, startWeightReminderScheduler } = await import('../dist/queues/weightReminderQueue.js'); const { weightReminderQueue, startWeightReminderScheduler, enqueueDailyWeightReminder } = await import('../dist/queues/weightReminderQueue.js');
const workers = []; const workers = [];
let logs = ''; let logs = '';
const poll = async (check, label) => { const poll = async (check, label) => {
@@ -91,6 +93,8 @@ try {
await finishWeightReminder(tokens[claims.indexOf(true)], false); await finishWeightReminder(tokens[claims.indexOf(true)], false);
console.log('PASS: schema initialization is repeatable; eligibility, roles, and concurrent claims'); console.log('PASS: schema initialization is repeatable; eligibility, roles, and concurrent claims');
// Simulate the schedule present before upgrading to daily reminders.
await weightReminderQueue.upsertJobScheduler('weight-reminders', { every: 300_000 }, { name: 'check-overdue-weights', data: {} });
startWorker(); startWorker(); startWorker(); startWorker();
await poll(async () => (await messages()).length === 4, 'initial grouped emails'); await poll(async () => (await messages()).length === 4, 'initial grouped emails');
await drain(); await drain();
@@ -110,22 +114,26 @@ try {
} }
assert.equal(body.To.length, 1); assert.equal(body.To.length, 1);
} }
assert.equal((await weightReminderQueue.getJobSchedulers()).length, 1); assert.equal((await weightReminderQueue.getJobSchedulers()).length, 0);
await poll(async () => Boolean(await weightReminderQueue.getJob(`weight-reminders-${getReminderDate()}`)), 'startup daily scheduler');
const dailyJobs = await Promise.all([enqueueDailyWeightReminder(), enqueueDailyWeightReminder()]);
assert.equal(dailyJobs[0].id, dailyJobs[1].id);
await drain();
assert.equal(await weightReminderQueue.getGlobalConcurrency(), 1); assert.equal(await weightReminderQueue.getGlobalConcurrency(), 1);
assert.equal((await runJob()).sent, 0); assert.equal((await runJob()).sent, 0);
assert.equal((await messages()).length, 4); assert.equal((await messages()).length, 4);
console.log('PASS: two real workers, one scheduler, grouped SMTP delivery, escaping, flock isolation, no immediate duplicates'); console.log('PASS: two real workers, one daily job, legacy schedule removed, grouped SMTP delivery, escaping, flock isolation, no immediate duplicates');
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '47 hours'"); await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP");
assert.equal((await runJob()).sent, 0); assert.equal((await runJob()).sent, 0);
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '48 hours'"); await db.query("UPDATE weight_reminder_deliveries SET delivered_at = ((CURRENT_TIMESTAMP AT TIME ZONE 'America/New_York')::date::timestamp AT TIME ZONE 'America/New_York') - INTERVAL '1 second'");
assert.equal((await runJob()).sent, 4); assert.equal((await runJob()).sent, 4);
assert.equal((await messages()).length, 8); assert.equal((await messages()).length, 8);
await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on) VALUES ($1, 91, CURRENT_DATE)", [birds.Boundary]); await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on) VALUES ($1, 91, CURRENT_DATE)", [birds.Boundary]);
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'"); await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'");
assert.ok((await listDueWeightReminders()).every(b => b.bird_id !== birds.Boundary)); assert.ok((await listDueWeightReminders()).every(b => b.bird_id !== birds.Boundary));
assert.equal((await runJob()).sent, 4); assert.equal((await runJob()).sent, 4);
console.log('PASS: no repeat before 48 hours, repeat after 48 hours, fresh weight resets eligibility'); console.log('PASS: no repeat on the same local date, daily repeat, fresh weight resets eligibility');
await stopWorkers(); await stopWorkers();
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'"); await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'");
+2 -2
View File
@@ -28,7 +28,7 @@ these local services. It does not use the application's configured SMTP account.
Coverage includes repeatable schema initialization, overdue and recent weights, Coverage includes repeatable schema initialization, overdue and recent weights,
birds without weights, memorial exclusions, accepted care roles, concurrent birds without weights, memorial exclusions, accepted care roles, concurrent
claims, one scheduler across two workers, grouped emails, HTML escaping, flock claims, one daily job across two workers, removal of the old five-minute schedule, grouped emails, HTML escaping, flock
isolation, 48-hour repeats, fresh weight resets, SMTP failures and recovery, isolation, daily repeats, fresh weight resets, SMTP failures and recovery,
worker shutdown, and disabling the scheduler. Repeat timing is tested by aging worker shutdown, and disabling the scheduler. Repeat timing is tested by aging
delivery timestamps rather than waiting two days. delivery timestamps rather than waiting two days.