diff --git a/.env.example b/.env.example index ccaa4b7..44efc09 100644 --- a/.env.example +++ b/.env.example @@ -40,5 +40,5 @@ STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled 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 diff --git a/README.md b/README.md index e28c9b1..8a9c7f3 100644 --- a/README.md +++ b/README.md @@ -163,12 +163,18 @@ npm run worker ### 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 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. +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 has no time of day. Editing an existing entry does not restart the clock; entering a historical weight does. Memorialized birds are excluded. diff --git a/backend/src/queues/weightReminderQueue.ts b/backend/src/queues/weightReminderQueue.ts index ac98391..7485719 100644 --- a/backend/src/queues/weightReminderQueue.ts +++ b/backend/src/queues/weightReminderQueue.ts @@ -1,4 +1,5 @@ import { Queue } from 'bullmq'; +import { getReminderDate } from '../reminders/reminderDate.js'; import { redisConnection } from './redisConnection.js'; 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 () => { - if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') { - await weightReminderQueue.removeJobScheduler('weight-reminders'); - return; - } + // Remove the previous five-minute schedule during upgrades and when disabled. + await weightReminderQueue.removeJobScheduler('weight-reminders'); + if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') return () => {}; await weightReminderQueue.setGlobalConcurrency(1); - await weightReminderQueue.upsertJobScheduler( - 'weight-reminders', - { every: 5 * 60_000 }, - { name: 'check-overdue-weights', data: {} }, - ); + let lastRunDate = ''; + const runIfNeeded = async () => { + const now = new Date(); + 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); }; }; diff --git a/backend/src/reminders/reminderDate.test.ts b/backend/src/reminders/reminderDate.test.ts new file mode 100644 index 0000000..c323299 --- /dev/null +++ b/backend/src/reminders/reminderDate.test.ts @@ -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; + } +}); diff --git a/backend/src/reminders/reminderDate.ts b/backend/src/reminders/reminderDate.ts new file mode 100644 index 0000000..baba36e --- /dev/null +++ b/backend/src/reminders/reminderDate.ts @@ -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); diff --git a/backend/src/reminders/weightReminders.ts b/backend/src/reminders/weightReminders.ts index 1663e29..da76e56 100644 --- a/backend/src/reminders/weightReminders.ts +++ b/backend/src/reminders/weightReminders.ts @@ -1,3 +1,4 @@ +import { getReminderTimeZone } from './reminderDate.js'; import { randomUUID } from 'node:crypto'; import { db } from '../db/client.js'; @@ -40,11 +41,11 @@ export const listDueWeightReminders = async () => { SELECT 1 FROM weight_reminder_deliveries d 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.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')) ) ORDER BY activity.workspace_id, activity.bird_id, recipients.recipient - `); + `, [getReminderTimeZone()]); 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 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 AND weight_reminder_deliveries.claimed_at <= CURRENT_TIMESTAMP - INTERVAL '15 minutes') 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); }; diff --git a/backend/src/worker.ts b/backend/src/worker.ts index 9c52815..4931cfa 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -32,6 +32,7 @@ import { import { redisConnection } from './queues/redisConnection.js'; import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js'; +let stopWeightReminderScheduler: (() => void) | undefined; let weightReminderWorker: Worker | null = null; let birdMilestoneWorker: Worker | null = null; let medicationReminderWorker: Worker | null = null; @@ -106,7 +107,7 @@ const startWorker = async () => { weightReminderWorker.on('failed', (job, error) => { console.error(`Weight reminder job failed: id=${job?.id ?? 'unknown'}`, error); }); - await startWeightReminderScheduler(); + stopWeightReminderScheduler = await startWeightReminderScheduler(); startBirdMilestoneReminderScheduler(); startMedicationReminderScheduler(); console.log('FlockPal worker started.'); @@ -114,6 +115,7 @@ const startWorker = async () => { const shutdown = async (signal: string) => { console.log(`FlockPal worker received ${signal}; shutting down.`); + stopWeightReminderScheduler?.(); await weightReminderWorker?.close(); await weightReminderQueue.close(); await birdMilestoneWorker?.close(); diff --git a/backend/test-integration/weightReminders.mjs b/backend/test-integration/weightReminders.mjs index ba72f3d..13e6907 100644 --- a/backend/test-integration/weightReminders.mjs +++ b/backend/test-integration/weightReminders.mjs @@ -14,11 +14,13 @@ Object.assign(process.env, { SMTP_USER: '', SMTP_PASS: '', SMTP_FROM_EMAIL: 'reminders@flockpal.test', SMTP_FROM_NAME: 'FlockPal Test', FRONTEND_URL: 'http://flockpal.test', MILESTONE_REMINDERS_ENABLED: 'false', MEDICATION_REMINDERS_ENABLED: 'false', WEIGHT_REMINDERS_ENABLED: 'true', + MILESTONE_REMINDER_TIME_ZONE: 'America/New_York', }); const { db } = await import('../dist/db/client.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 { weightReminderQueue, startWeightReminderScheduler } = await import('../dist/queues/weightReminderQueue.js'); +const { weightReminderQueue, startWeightReminderScheduler, enqueueDailyWeightReminder } = await import('../dist/queues/weightReminderQueue.js'); const workers = []; let logs = ''; const poll = async (check, label) => { @@ -91,6 +93,8 @@ try { await finishWeightReminder(tokens[claims.indexOf(true)], false); 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(); await poll(async () => (await messages()).length === 4, 'initial grouped emails'); await drain(); @@ -110,22 +114,26 @@ try { } 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 runJob()).sent, 0); 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); - 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 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("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'"); assert.ok((await listDueWeightReminders()).every(b => b.bird_id !== birds.Boundary)); 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 db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'"); diff --git a/docs/WEIGHT_REMINDER_TESTING.md b/docs/WEIGHT_REMINDER_TESTING.md index eaf8b75..ecbef01 100644 --- a/docs/WEIGHT_REMINDER_TESTING.md +++ b/docs/WEIGHT_REMINDER_TESTING.md @@ -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, birds without weights, memorial exclusions, accepted care roles, concurrent -claims, one scheduler across two workers, grouped emails, HTML escaping, flock -isolation, 48-hour repeats, fresh weight resets, SMTP failures and recovery, +claims, one daily job across two workers, removal of the old five-minute schedule, grouped emails, HTML escaping, flock +isolation, daily repeats, fresh weight resets, SMTP failures and recovery, worker shutdown, and disabling the scheduler. Repeat timing is tested by aging delivery timestamps rather than waiting two days.