From 5abc7f6174d7bb349be616d56f542494b7adad62 Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Sat, 5 Sep 2026 22:51:39 -0400 Subject: [PATCH] Added missing weight emails --- .env.example | 3 + README.md | 20 +++ backend/src/app.ts | 14 ++ backend/src/db/schema.ts | 11 ++ backend/src/queues/weightReminderQueue.ts | 26 +++ backend/src/reminders/weightReminders.test.ts | 60 +++++++ backend/src/reminders/weightReminders.ts | 121 +++++++++++++ backend/src/worker.ts | 16 ++ .../compose.weight-reminders.yml | 26 +++ backend/test-integration/weightReminders.mjs | 166 ++++++++++++++++++ docker-compose.prod.yml | 2 + docker-compose.yml | 2 + docs/WEIGHT_REMINDER_TESTING.md | 34 ++++ 13 files changed, 501 insertions(+) create mode 100644 backend/src/queues/weightReminderQueue.ts create mode 100644 backend/src/reminders/weightReminders.test.ts create mode 100644 backend/src/reminders/weightReminders.ts create mode 100644 backend/test-integration/compose.weight-reminders.yml create mode 100644 backend/test-integration/weightReminders.mjs create mode 100644 docs/WEIGHT_REMINDER_TESTING.md diff --git a/.env.example b/.env.example index d97f1a7..ccaa4b7 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,6 @@ STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY= 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. +WEIGHT_REMINDERS_ENABLED=true diff --git a/README.md b/README.md index 4f9f442..e28c9b1 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,26 @@ npm run build npm run worker ``` +### Overdue weight emails + +The worker checks every five minutes 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 +48-hour clock. Birds without weights use their profile creation time. + +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. + +`WEIGHT_REMINDERS_ENABLED` defaults to `true` in both Compose stacks. Set it to +`false` to disable delivery. The worker, Redis, Postgres, and existing SMTP settings +must be available. The schema initializer creates the delivery tracking table. +Missing SMTP configuration or failed sends leave reminders eligible for retry. +Delivery tracking prevents ordinary repeat sends and concurrent claims; as with +other SMTP delivery, a crash after acceptance but before saving delivery can cause +a duplicate on retry. + ## Auth and flock notes - One user can belong to multiple flocks. diff --git a/backend/src/app.ts b/backend/src/app.ts index 989c6ab..ce01685 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,3 +1,4 @@ +import type { WeightReminder } from './reminders/weightReminders.js'; import crypto from 'crypto'; import { existsSync } from 'fs'; import path from 'path'; @@ -2270,6 +2271,19 @@ export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) = }; }; +export const sendWeightReminderNotification = async (reminders: WeightReminder[]): Promise => { + if (!mailTransport || !reminders.length) return false; + const first = reminders[0]; + const result = await mailTransport.sendMail({ + from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail, + to: first.recipient, + subject: `Weight reminders for ${first.workspace_name}`, + text: `These birds in ${first.workspace_name} haven't had a new weight entry in at least 48 hours:\n\n${reminders.map((bird) => `- ${bird.bird_name}`).join('\n')}\n\nOpen FlockPal to record their weights: ${frontendBaseUrl}`, + html: `

These birds in ${escapeHtml(first.workspace_name)} haven't had a new weight entry in at least 48 hours:

Open FlockPal to record their weights

`, + }); + return result.accepted.length > 0; +}; + export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => { const reminders = await listDueMedicationReminders(runDate, currentTime); let sent = 0; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 4d83ff6..49dbf15 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -480,6 +480,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => { UNIQUE (bird_id, recorded_on) ); + CREATE TABLE IF NOT EXISTS weight_reminder_deliveries ( + bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, + workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + activity_at TIMESTAMPTZ NOT NULL, + recipient TEXT NOT NULL, + claim_token UUID NOT NULL, + claimed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + delivered_at TIMESTAMPTZ, + PRIMARY KEY (bird_id, workspace_id, activity_at, recipient) + ); + CREATE TABLE IF NOT EXISTS vet_visits ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, diff --git a/backend/src/queues/weightReminderQueue.ts b/backend/src/queues/weightReminderQueue.ts new file mode 100644 index 0000000..ac98391 --- /dev/null +++ b/backend/src/queues/weightReminderQueue.ts @@ -0,0 +1,26 @@ +import { Queue } from 'bullmq'; +import { redisConnection } from './redisConnection.js'; + +export const weightReminderQueueName = 'weight-reminders'; +export const weightReminderQueue = new Queue(weightReminderQueueName, { + connection: redisConnection, + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 60_000 }, + removeOnComplete: 100, + removeOnFail: 1000, + }, +}); + +export const startWeightReminderScheduler = async () => { + if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') { + await weightReminderQueue.removeJobScheduler('weight-reminders'); + return; + } + await weightReminderQueue.setGlobalConcurrency(1); + await weightReminderQueue.upsertJobScheduler( + 'weight-reminders', + { every: 5 * 60_000 }, + { name: 'check-overdue-weights', data: {} }, + ); +}; diff --git a/backend/src/reminders/weightReminders.test.ts b/backend/src/reminders/weightReminders.test.ts new file mode 100644 index 0000000..e3a73cd --- /dev/null +++ b/backend/src/reminders/weightReminders.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { runWeightReminders, type WeightReminder } from './weightReminders.js'; + +const bird = (id: string, workspace = 1, recipient = 'owner@example.com'): WeightReminder => ({ + bird_id: id, workspace_id: workspace, recipient, + bird_name: id, workspace_name: `Flock ${workspace}`, activity_at: '2026-09-01T12:00:00Z', +}); + +test('groups overdue birds into one email per flock and recipient', async () => { + const sent: WeightReminder[][] = []; + const finished: boolean[] = []; + const result = await runWeightReminders(async (birds) => { sent.push(birds); return true; }, { + list: async () => [bird('a'), bird('b'), bird('c', 2), bird('a', 1, 'caregiver@example.com')], + claim: async () => true, + finish: async (_token, delivered) => { finished.push(delivered); }, + }); + assert.deepEqual(sent.map((group) => group.map((item) => item.bird_id)), [['a', 'b'], ['c'], ['a']]); + assert.deepEqual(finished, [true, true, true, true]); + assert.equal(result.sent, 3); +}); + +test('omits birds whose claim was lost or whose weight changed', async () => { + const sent: WeightReminder[][] = []; + await runWeightReminders(async (birds) => { sent.push(birds); return true; }, { + list: async () => [bird('a'), bird('b')], + claim: async (reminder) => reminder.bird_id === 'b', finish: async () => {}, + }); + assert.deepEqual(sent.map((group) => group.map((item) => item.bird_id)), [['b']]); +}); + +test('does not email when all claims are unavailable', async () => { + await runWeightReminders(async () => { assert.fail('Unexpected email'); }, { + list: async () => [bird('a')], claim: async () => false, finish: async () => {}, + }); +}); + +test('missing SMTP releases all claims without recording delivery', async () => { + const finished: boolean[] = []; + const result = await runWeightReminders(async () => false, { + list: async () => [bird('a'), bird('b')], claim: async () => true, + finish: async (_token, delivered) => { finished.push(delivered); }, + }); + assert.deepEqual(finished, [false, false]); + assert.equal(result.sent, 0); +}); + +test('SMTP failure releases claims and still processes other flocks', async () => { + const finished: boolean[] = []; + const result = await runWeightReminders(async (birds) => { + if (birds[0].workspace_id === 1) throw new Error('SMTP unavailable'); + return true; + }, { + list: async () => [bird('a'), bird('b'), bird('c', 2)], claim: async () => true, + finish: async (_token, delivered) => { finished.push(delivered); }, + }); + assert.deepEqual(finished, [false, false, true]); + assert.equal(result.sent, 1); + assert.equal(result.failed, 1); +}); diff --git a/backend/src/reminders/weightReminders.ts b/backend/src/reminders/weightReminders.ts new file mode 100644 index 0000000..1663e29 --- /dev/null +++ b/backend/src/reminders/weightReminders.ts @@ -0,0 +1,121 @@ +import { randomUUID } from 'node:crypto'; +import { db } from '../db/client.js'; + +export type WeightReminder = { + bird_id: string; + workspace_id: number; + bird_name: string; + workspace_name: string; + activity_at: string; + recipient: string; +}; + +// Use entry creation time: recorded_on is a date and cannot measure elapsed hours. +// Editing an existing entry does not reset the reminder clock. +export const listDueWeightReminders = async () => { + const result = await db.query(` + WITH activity AS ( + SELECT birds.id AS bird_id, birds.workspace_id, birds.name AS bird_name, + workspaces.name AS workspace_name, + GREATEST(birds.created_at, COALESCE(MAX(weight_records.created_at), birds.created_at)) AS activity_at + FROM birds + JOIN workspaces ON workspaces.id = birds.workspace_id + LEFT JOIN weight_records ON weight_records.bird_id = birds.id + WHERE birds.memorialized_at IS NULL + GROUP BY birds.id, workspaces.name + ), recipients AS ( + SELECT DISTINCT workspace_id, LOWER(TRIM(users.email)) AS recipient + FROM workspace_members + JOIN users ON users.id = workspace_members.user_id + WHERE accepted_at IS NOT NULL + AND role IN ('owner', 'assistant', 'caregiver') + ) + SELECT activity.bird_id, activity.workspace_id, activity.bird_name, activity.workspace_name, + activity.activity_at::text, recipients.recipient + FROM activity + JOIN recipients USING (workspace_id) + WHERE activity_at <= CURRENT_TIMESTAMP - INTERVAL '48 hours' + AND recipients.recipient <> '' + AND NOT EXISTS ( + 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' + 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 + `); + return result.rows; +}; + +export const claimWeightReminder = async (reminder: WeightReminder, token: string) => { + const result = await db.query(` + INSERT INTO weight_reminder_deliveries (bird_id, workspace_id, activity_at, recipient, claim_token) + SELECT $1, $2, $3, $4, $5 + WHERE EXISTS ( + SELECT 1 FROM birds + WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL + AND GREATEST(created_at, COALESCE((SELECT MAX(created_at) FROM weight_records WHERE bird_id = $1), created_at)) = $3::timestamptz + ) + 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' + 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]); + return Boolean(result.rowCount); +}; + +export const finishWeightReminder = async (token: string, delivered: boolean) => { + if (delivered) { + await db.query('UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP WHERE claim_token = $1', [token]); + } else { + await db.query('DELETE FROM weight_reminder_deliveries WHERE claim_token = $1 AND delivered_at IS NULL', [token]); + } +}; + +type Dependencies = { + list: typeof listDueWeightReminders; + claim: typeof claimWeightReminder; + finish: typeof finishWeightReminder; +}; + +export const runWeightReminders = async ( + send: (reminders: WeightReminder[]) => Promise, + dependencies: Dependencies = { list: listDueWeightReminders, claim: claimWeightReminder, finish: finishWeightReminder }, +) => { + const reminders = await dependencies.list(); + const result = { checked: reminders.length, sent: 0, skipped: 0, failed: 0 }; + const groups = new Map(); + for (const reminder of reminders) { + const key = JSON.stringify([reminder.workspace_id, reminder.recipient]); + groups.set(key, [...(groups.get(key) ?? []), reminder]); + } + for (const group of groups.values()) { + const claimed: { reminder: WeightReminder; token: string }[] = []; + let delivered = false; + try { + for (const reminder of group) { + const token = randomUUID(); + if (await dependencies.claim(reminder, token)) claimed.push({ reminder, token }); + else result.skipped += 1; + } + if (!claimed.length) continue; + delivered = await send(claimed.map(({ reminder }) => reminder)); + for (const { token } of claimed) await dependencies.finish(token, delivered); + if (delivered) result.sent += 1; + else result.skipped += claimed.length; + } catch (error) { + result.failed += 1; + // Keep leases if SMTP accepted the email but saving delivery failed. + if (!delivered) { + for (const { token } of claimed) { + await dependencies.finish(token, false).catch((cleanupError) => console.error('Weight reminder claim cleanup failed', cleanupError)); + } + } + console.error(`Weight reminder failed for flock ${group[0].workspace_id}`, error); + } + } + return result; +}; diff --git a/backend/src/worker.ts b/backend/src/worker.ts index 7611d0e..9c52815 100644 --- a/backend/src/worker.ts +++ b/backend/src/worker.ts @@ -1,8 +1,11 @@ +import { runWeightReminders } from './reminders/weightReminders.js'; +import { weightReminderQueue, weightReminderQueueName, startWeightReminderScheduler } from './queues/weightReminderQueue.js'; import { Worker } from 'bullmq'; import { ensureSchema } from './db/schema.js'; import { db } from './db/client.js'; import { + sendWeightReminderNotification, runBirdMilestoneReminders, runMedicationReminders, startBirdMilestoneReminderScheduler, @@ -29,6 +32,7 @@ import { import { redisConnection } from './queues/redisConnection.js'; import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js'; +let weightReminderWorker: Worker | null = null; let birdMilestoneWorker: Worker | null = null; let medicationReminderWorker: Worker | null = null; let adoptionReportWorker: Worker | null = null; @@ -93,6 +97,16 @@ const startWorker = async () => { console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error); }); + weightReminderWorker = new Worker(weightReminderQueueName, async () => { + if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') return; + const result = await runWeightReminders(sendWeightReminderNotification); + console.log('Weight reminder job completed', result); + return result; + }, { connection: redisConnection, concurrency: 1 }); + weightReminderWorker.on('failed', (job, error) => { + console.error(`Weight reminder job failed: id=${job?.id ?? 'unknown'}`, error); + }); + await startWeightReminderScheduler(); startBirdMilestoneReminderScheduler(); startMedicationReminderScheduler(); console.log('FlockPal worker started.'); @@ -100,6 +114,8 @@ const startWorker = async () => { const shutdown = async (signal: string) => { console.log(`FlockPal worker received ${signal}; shutting down.`); + await weightReminderWorker?.close(); + await weightReminderQueue.close(); await birdMilestoneWorker?.close(); await medicationReminderWorker?.close(); await adoptionReportWorker?.close(); diff --git a/backend/test-integration/compose.weight-reminders.yml b/backend/test-integration/compose.weight-reminders.yml new file mode 100644 index 0000000..7ae6464 --- /dev/null +++ b/backend/test-integration/compose.weight-reminders.yml @@ -0,0 +1,26 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: flockpal_test + POSTGRES_PASSWORD: isolated_test_password + POSTGRES_DB: flockpal_weight_test + ports: + - '127.0.0.1:25432:5432' + tmpfs: + - /var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U flockpal_test -d flockpal_weight_test'] + interval: 1s + timeout: 3s + retries: 30 + redis: + image: redis:7-alpine + command: ['redis-server', '--save', '', '--appendonly', 'no'] + ports: + - '127.0.0.1:26379:6379' + mail: + image: axllent/mailpit:latest + ports: + - '127.0.0.1:21025:1025' + - '127.0.0.1:28025:8025' diff --git a/backend/test-integration/weightReminders.mjs b/backend/test-integration/weightReminders.mjs new file mode 100644 index 0000000..ba72f3d --- /dev/null +++ b/backend/test-integration/weightReminders.mjs @@ -0,0 +1,166 @@ +// Run only against disposable local Postgres, Redis, and Mailpit services. +// See docs/WEIGHT_REMINDER_TESTING.md for the isolated Compose setup. +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; + +Object.assign(process.env, { + POSTGRES_HOST: '127.0.0.1', POSTGRES_PORT: '25432', POSTGRES_DB: 'flockpal_weight_test', + POSTGRES_USER: 'flockpal_test', POSTGRES_PASSWORD: 'isolated_test_password', + REDIS_URL: 'redis://127.0.0.1:26379', + SMTP_HOST: '127.0.0.1', SMTP_PORT: '21025', SMTP_SECURE: 'false', + 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', +}); +const { db } = await import('../dist/db/client.js'); +const { ensureSchema } = await import('../dist/db/schema.js'); +const { listDueWeightReminders, claimWeightReminder, finishWeightReminder } = await import('../dist/reminders/weightReminders.js'); +const { weightReminderQueue, startWeightReminderScheduler } = await import('../dist/queues/weightReminderQueue.js'); +const workers = []; +let logs = ''; +const poll = async (check, label) => { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (await check()) return; + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Timed out: ${label}\n${logs}`); +}; +const messages = async () => (await (await fetch('http://127.0.0.1:28025/api/v1/messages')).json()).messages; +const startWorker = () => { + const child = spawn(process.execPath, [fileURLToPath(new URL('../dist/worker.js', import.meta.url))], { + cwd: '/tmp', env: process.env, stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', data => { logs += data; }); + child.stderr.on('data', data => { logs += data; }); + workers.push(child); +}; +const stopWorkers = async () => { + const exits = workers.map(child => once(child, 'exit')); + for (const child of workers) child.kill('SIGTERM'); + await Promise.all(exits); + workers.length = 0; +}; +const drain = async () => { + await poll(async () => { + const counts = await weightReminderQueue.getJobCounts('active', 'waiting'); + return !counts.active && !counts.waiting; + }, 'queue drain'); +}; +const runJob = async () => { + const job = await weightReminderQueue.add('integration-check', {}); + await poll(async () => ['completed', 'failed'].includes(await job.getState()), 'manual job'); + assert.equal(await job.getState(), 'completed'); + return (await weightReminderQueue.getJob(job.id)).returnvalue; +}; +try { + await ensureSchema(); + await ensureSchema(); + assert.equal((await db.query('SELECT COUNT(*)::int AS count FROM birds')).rows[0].count, 0, 'Use a fresh disposable database'); + assert.equal((await messages()).length, 0, 'Use a fresh email catcher'); + await db.query("INSERT INTO workspaces (id, name) VALUES (101, 'Test Flock'), (102, 'Other Flock')"); + const ids = {}; + for (const [role, accepted] of [['owner', true], ['assistant', true], ['caregiver', true], ['viewer', true], ['pending', false]]) { + const id = randomUUID(); ids[role] = id; + await db.query('INSERT INTO users (id, email, name) VALUES ($1, $2, $3)', [id, `${role}@example.test`, role]); + await db.query(`INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at) + VALUES (101, $1, $2, $3, $4, ${accepted ? 'CURRENT_TIMESTAMP' : 'NULL'})`, [id, `${role}@example.test`, role, role === 'pending' ? 'caregiver' : role]); + } + await db.query("INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at) VALUES (102, $1, 'owner@example.test', 'owner', 'owner', CURRENT_TIMESTAMP)", [ids.owner]); + const birds = {}; + for (const [name, age, memorial, workspace] of [ + ['Kiwi & ', 72, false, 101], ['Boundary', 48, false, 101], + ['No weights', null, false, 101], ['Recent', 47, false, 101], + ['Memorial', 72, true, 101], ['Other bird', 72, false, 102], + ]) { + const id = randomUUID(); birds[name] = id; + await db.query(`INSERT INTO birds (id, workspace_id, name, species, created_at, memorialized_at) + VALUES ($1, $2, $3, 'Cockatiel', CURRENT_TIMESTAMP - INTERVAL '100 hours', ${memorial ? 'CURRENT_TIMESTAMP' : 'NULL'})`, [id, workspace, name]); + if (age !== null) await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on, created_at) VALUES ($1, 90, CURRENT_DATE - 3, CURRENT_TIMESTAMP - $2 * INTERVAL '1 hour')", [id, age]); + } + const due = await listDueWeightReminders(); + assert.equal(due.length, 10); + assert.deepEqual(new Set(due.map(b => b.recipient)), new Set(['owner@example.test', 'assistant@example.test', 'caregiver@example.test'])); + const candidate = due[0]; + const tokens = [randomUUID(), randomUUID()]; + const claims = await Promise.all(tokens.map(token => claimWeightReminder(candidate, token))); + assert.equal(claims.filter(Boolean).length, 1, 'Only one concurrent claim wins'); + await finishWeightReminder(tokens[claims.indexOf(true)], false); + console.log('PASS: schema initialization is repeatable; eligibility, roles, and concurrent claims'); + + startWorker(); startWorker(); + await poll(async () => (await messages()).length === 4, 'initial grouped emails'); + await drain(); + const initial = await messages(); + const bodies = await Promise.all(initial.map(async message => (await fetch(`http://127.0.0.1:28025/api/v1/message/${message.ID}`)).json())); + for (const body of bodies) { + if (body.Subject === 'Weight reminders for Test Flock') { + for (const name of ['Kiwi & ', 'Boundary', 'No weights']) assert.ok(body.Text.includes(name)); + assert.ok(body.HTML.includes('Kiwi & <Peep>')); + assert.ok(!body.Text.includes('Memorial')); + assert.ok(!body.Text.includes('Recent')); + assert.ok(!body.Text.includes('Other bird')); + } else { + assert.equal(body.Subject, 'Weight reminders for Other Flock'); + assert.ok(body.Text.includes('Other bird')); + assert.ok(!body.Text.includes('Boundary')); + } + assert.equal(body.To.length, 1); + } + assert.equal((await weightReminderQueue.getJobSchedulers()).length, 1); + 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'); + + await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '47 hours'"); + assert.equal((await runJob()).sent, 0); + await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '48 hours'"); + 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'); + + await stopWorkers(); + await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'"); + const beforeFailures = (await messages()).length; + process.env.SMTP_HOST = ''; + startWorker(); + assert.equal((await runJob()).sent, 0); + assert.equal((await listDueWeightReminders()).length, 7); + await stopWorkers(); + + process.env.SMTP_HOST = '127.0.0.1'; + process.env.SMTP_PORT = '21026'; // No SMTP listener: exercise a real refused connection. + startWorker(); + const failed = await runJob(); + assert.equal(failed.failed, 4); + assert.equal(failed.sent, 0); + assert.equal((await listDueWeightReminders()).length, 7); + assert.equal((await messages()).length, beforeFailures); + await stopWorkers(); + + process.env.SMTP_PORT = '21025'; + startWorker(); + await runJob(); + await poll(async () => (await messages()).length === beforeFailures + 4, 'delivery after SMTP recovery'); + await drain(); + await stopWorkers(); + console.log('PASS: missing SMTP, real SMTP connection failure, released claims, successful retry'); + + process.env.WEIGHT_REMINDERS_ENABLED = 'false'; + await startWeightReminderScheduler(); + assert.equal((await weightReminderQueue.getJobSchedulers()).length, 0); + console.log('PASS: graceful worker shutdown and disabled scheduler removal'); + console.log('All Postgres / Redis / SMTP integration checks passed.'); +} finally { + for (const child of workers) child.kill('SIGKILL'); + await weightReminderQueue.close(); + await db.close(); +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a9bae56..522c32f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,6 +60,7 @@ services: RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} + WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true} MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} @@ -142,6 +143,7 @@ services: RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} + WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true} MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} SMTP_HOST: ${SMTP_HOST:-} diff --git a/docker-compose.yml b/docker-compose.yml index b7f4446..b56c17c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -58,6 +58,7 @@ services: RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} + WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true} MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} @@ -135,6 +136,7 @@ services: RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true} + WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true} MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} SMTP_HOST: ${SMTP_HOST:-} diff --git a/docs/WEIGHT_REMINDER_TESTING.md b/docs/WEIGHT_REMINDER_TESTING.md new file mode 100644 index 0000000..eaf8b75 --- /dev/null +++ b/docs/WEIGHT_REMINDER_TESTING.md @@ -0,0 +1,34 @@ +# Weight reminder tests + +From the repository root, run the unit tests and builds: + +```bash +npm ci --prefix backend +npm ci --prefix frontend +npm test --prefix backend +npm run build --prefix backend +npm run build --prefix frontend +``` + +The integration test uses real Postgres 16, Redis 7, two worker processes, and +Mailpit to capture SMTP messages locally. It requires Docker and free localhost +ports 25432, 26379, 21025, and 28025. Port 21026 must remain unused for the SMTP +failure check. Test addresses use `.test`; Mailpit does not relay mail externally. + +```bash +docker compose -p flockpal-weight-test -f backend/test-integration/compose.weight-reminders.yml up -d --wait +node backend/test-integration/weightReminders.mjs +docker compose -p flockpal-weight-test -f backend/test-integration/compose.weight-reminders.yml down --volumes +``` + +Always run the last command when finished, including after a failed test. Start +fresh containers for each run. Postgres data is disposable and held in tmpfs. +The test overrides database, Redis, and SMTP environment variables to use only +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, +worker shutdown, and disabling the scheduler. Repeat timing is tested by aging +delivery timestamps rather than waiting two days.