changed weight reminder schedule
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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') {
|
||||
// Remove the previous five-minute schedule during upgrades and when disabled.
|
||||
await weightReminderQueue.removeJobScheduler('weight-reminders');
|
||||
return;
|
||||
}
|
||||
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); };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | 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();
|
||||
|
||||
@@ -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'");
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user