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
+25 -9
View File
@@ -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); };
};
@@ -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 { 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);
};
+3 -1
View File
@@ -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 -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',
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'");