Added missing weight emails
Deploy / deploy-dev (push) Successful in 2m15s
Deploy / deploy-prod (push) Skipped

This commit is contained in:
blaisadmin
2026-09-05 22:51:39 -04:00
parent b4254a68fa
commit 5abc7f6174
13 changed files with 501 additions and 0 deletions
+14
View File
@@ -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<boolean> => {
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: `<p>These birds in ${escapeHtml(first.workspace_name)} haven't had a new weight entry in at least 48 hours:</p><ul>${reminders.map((bird) => `<li>${escapeHtml(bird.bird_name)}</li>`).join('')}</ul><p><a href="${escapeHtml(frontendBaseUrl)}">Open FlockPal to record their weights</a></p>`,
});
return result.accepted.length > 0;
};
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
const reminders = await listDueMedicationReminders(runDate, currentTime);
let sent = 0;
+11
View File
@@ -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,
+26
View File
@@ -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: {} },
);
};
@@ -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);
});
+121
View File
@@ -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<WeightReminder>(`
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<boolean>,
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<string, WeightReminder[]>();
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;
};
+16
View File
@@ -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<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | 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();
@@ -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'
@@ -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 & <Peep>', 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 & <Peep>', 'Boundary', 'No weights']) assert.ok(body.Text.includes(name));
assert.ok(body.HTML.includes('Kiwi &amp; &lt;Peep&gt;'));
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();
}