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
+3
View File
@@ -39,3 +39,6 @@ STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY=
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal 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
+20
View File
@@ -161,6 +161,26 @@ npm run build
npm run worker 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 ## Auth and flock notes
- One user can belong to multiple flocks. - One user can belong to multiple flocks.
+14
View File
@@ -1,3 +1,4 @@
import type { WeightReminder } from './reminders/weightReminders.js';
import crypto from 'crypto'; import crypto from 'crypto';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import path from 'path'; 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()) => { export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
const reminders = await listDueMedicationReminders(runDate, currentTime); const reminders = await listDueMedicationReminders(runDate, currentTime);
let sent = 0; let sent = 0;
+11
View File
@@ -480,6 +480,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
UNIQUE (bird_id, recorded_on) 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 ( CREATE TABLE IF NOT EXISTS vet_visits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE, 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 { Worker } from 'bullmq';
import { ensureSchema } from './db/schema.js'; import { ensureSchema } from './db/schema.js';
import { db } from './db/client.js'; import { db } from './db/client.js';
import { import {
sendWeightReminderNotification,
runBirdMilestoneReminders, runBirdMilestoneReminders,
runMedicationReminders, runMedicationReminders,
startBirdMilestoneReminderScheduler, startBirdMilestoneReminderScheduler,
@@ -29,6 +32,7 @@ import {
import { redisConnection } from './queues/redisConnection.js'; import { redisConnection } from './queues/redisConnection.js';
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js'; import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
let weightReminderWorker: Worker | null = null;
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null; let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null; let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | 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); 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(); startBirdMilestoneReminderScheduler();
startMedicationReminderScheduler(); startMedicationReminderScheduler();
console.log('FlockPal worker started.'); console.log('FlockPal worker started.');
@@ -100,6 +114,8 @@ const startWorker = async () => {
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
console.log(`FlockPal worker received ${signal}; shutting down.`); console.log(`FlockPal worker received ${signal}; shutting down.`);
await weightReminderWorker?.close();
await weightReminderQueue.close();
await birdMilestoneWorker?.close(); await birdMilestoneWorker?.close();
await medicationReminderWorker?.close(); await medicationReminderWorker?.close();
await adoptionReportWorker?.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();
}
+2
View File
@@ -60,6 +60,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} 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} 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} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true} MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
@@ -142,6 +143,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} 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} 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} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true} MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
+2
View File
@@ -58,6 +58,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} 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} 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} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true} MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
@@ -135,6 +136,7 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app} 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} 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} MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true} MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York} MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
+34
View File
@@ -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.