From dc3d651b08aa5ea4a6daa57d72776af0e3271aed Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 01:33:44 +0530 Subject: [PATCH] feat(reminders): dated reminder_schedule table + resolveSchedule resolver (Phase 4) reminder_schedule (id, rule_kind, effective_from/_to, day_offsets CSV, subject, body) added to SCHEMA (CREATE TABLE IF NOT EXISTS, exec'd on every open = the additive migration path). resolveSchedule(db, ruleKind, today) picks the row active on the business date (from<=today, to NULL or >today, latest from wins) with code-constant fallbacks (quote_followup 3/7/14 + spec s7 subject/body; invoice_overdue 7/15/30). seedIfEmpty seeds one open-ended dated row per kind, audited in the same transaction. Co-Authored-By: Claude Fable 5 --- apps/hq/src/db.ts | 6 ++ apps/hq/src/repos-reminders.ts | 64 ++++++++++++++++ apps/hq/src/seed.ts | 22 ++++++ apps/hq/test/reminder-schedule.test.ts | 102 +++++++++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 apps/hq/test/reminder-schedule.test.ts diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index 8d3785a..e0bf4b9 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -144,6 +144,12 @@ CREATE TABLE IF NOT EXISTS reminder ( error TEXT, created_at TEXT NOT NULL, sent_at TEXT, UNIQUE (rule_kind, subject_id, due_period) -- the idempotency key: at-most-once per due-period ); +CREATE TABLE IF NOT EXISTS reminder_schedule ( + id TEXT PRIMARY KEY, rule_kind TEXT NOT NULL, -- 'quote_followup' | 'invoice_overdue' (validated in repo) + effective_from TEXT NOT NULL, effective_to TEXT, -- dated config window (rule 3): active when from<=today AND (to IS NULL OR to>today) + day_offsets TEXT NOT NULL, -- CSV day milestones since anchor, e.g. '3,7,14' + subject TEXT, body TEXT -- dated message text (quote_followup); NULL = code-constant fallback +); CREATE TABLE IF NOT EXISTS aws_usage ( id TEXT PRIMARY KEY, client_id TEXT NOT NULL, month TEXT NOT NULL, -- 'YYYY-MM' diff --git a/apps/hq/src/repos-reminders.ts b/apps/hq/src/repos-reminders.ts index 504bb3b..796e89c 100644 --- a/apps/hq/src/repos-reminders.ts +++ b/apps/hq/src/repos-reminders.ts @@ -109,6 +109,70 @@ export function dismissReminder(db: DB, userId: string, id: string): Reminder { return setReminderStatus(db, userId, id, 'dismissed') } +// ---------- dated reminder schedule (rule 3 / D-REMIND — spec §4, §7) ---------- + +/** Rule kinds whose cadence lives in reminder_schedule (dated config rows). */ +export type ScheduleRuleKind = 'quote_followup' | 'invoice_overdue' + +export interface ResolvedSchedule { + ruleKind: ScheduleRuleKind + dayOffsets: number[] // ascending, deduped day milestones since the anchor event + subject: string | null // message text; null only where the code template owns it + body: string | null + source: 'db' | 'default' // whether a dated row resolved or the code constant applied +} + +/** Code-constant fallbacks — the feature works before any row is seeded, and a + * wiped/missing row can never silence the reminder engine. Cadence *changes* + * are new dated rows, never edits here (rule 3). */ +export const SCHEDULE_DEFAULTS: Record< + ScheduleRuleKind, { dayOffsets: number[]; subject: string | null; body: string | null } +> = { + quote_followup: { + dayOffsets: [3, 7, 14], + subject: 'Following up on our quotation {ref} ({companyName})', + body: + 'Dear {clientName}, we wanted to check whether you had a chance to review our quotation {ref}. ' + + 'You can view it any time here: {shareUrl}. We\'d be glad to answer any questions. ' + + 'Warm regards, {companyName}.', + }, + // invoice_overdue mail text stays in reminder-templates.ts — only the cadence is dated here. + invoice_overdue: { dayOffsets: [7, 15, 30], subject: null, body: null }, +} + +/** Parse a day_offsets CSV ('3,7,14') → ascending unique positive integers. */ +function parseDayOffsets(csv: string): number[] { + const days = csv.split(',') + .map((s) => Number(s.trim())) + .filter((n) => Number.isInteger(n) && n > 0) + return [...new Set(days)].sort((a, b) => a - b) +} + +/** Resolve the schedule active on `today` (YYYY-MM-DD): the reminder_schedule row + * where effective_from <= today AND (effective_to IS NULL OR effective_to > today), + * latest effective_from winning. No usable row → the code-constant default. A row + * that sets cadence but leaves subject/body NULL inherits the default message text. */ +export function resolveSchedule(db: DB, ruleKind: ScheduleRuleKind, today: string): ResolvedSchedule { + const fallback = SCHEDULE_DEFAULTS[ruleKind] + const row = db.prepare( + `SELECT day_offsets, subject, body FROM reminder_schedule + WHERE rule_kind=? AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?) + ORDER BY effective_from DESC LIMIT 1`, + ).get(ruleKind, today, today) as { day_offsets: string; subject: string | null; body: string | null } | undefined + if (row !== undefined) { + const dayOffsets = parseDayOffsets(row.day_offsets) + if (dayOffsets.length > 0) { + return { + ruleKind, dayOffsets, + subject: row.subject ?? fallback.subject, body: row.body ?? fallback.body, + source: 'db', + } + } + // Row present but no usable offsets — never let bad config silence the engine. + } + return { ruleKind, dayOffsets: [...fallback.dayOffsets], subject: fallback.subject, body: fallback.body, source: 'default' } +} + // ---------- settings helpers (shared by scheduler + bounce poller) ---------- export function getSetting(db: DB, key: string): string | null { diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index 793df68..576d66d 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -1,8 +1,10 @@ import { randomBytes } from 'node:crypto' +import { uuidv7 } from '@sims/domain' import { createStaff } from './auth' import { writeAudit } from './audit' import type { DB } from './db' import { createModule } from './repos-modules' +import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders' /** First-boot seed: owner login, company settings placeholders, GST18 tax class. */ @@ -68,6 +70,26 @@ export function seedIfEmpty(db: DB): void { for (const [key, value] of Object.entries(REMINDER_SETTINGS)) seededReminderSettings += insert.run(key, value).changes if (seededReminderSettings > 0) writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS) + // Dated reminder schedules (rule 3 / D-REMIND): one open-ended row per rule kind, + // matching the code-constant fallback. A cadence change is a NEW dated row, not an edit. + const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue'] + for (const ruleKind of scheduleKinds) { + const have = db.prepare(`SELECT COUNT(*) AS n FROM reminder_schedule WHERE rule_kind=?`).get(ruleKind) as { n: number } + if (have.n > 0) continue + const d = SCHEDULE_DEFAULTS[ruleKind] + const id = uuidv7() + const effectiveFrom = '2020-01-01' // predates all HQ data — active for every business date + db.transaction(() => { + db.prepare( + `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) + VALUES (?, ?, ?, NULL, ?, ?, ?)`, + ).run(id, ruleKind, effectiveFrom, d.dayOffsets.join(','), d.subject, d.body) + writeAudit(db, 'system', 'seed', 'reminder_schedule', id, undefined, { + ruleKind, effectiveFrom, dayOffsets: d.dayOffsets.join(','), + }) + })() + } + const amc = db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get() as { n: number } if (amc.n === 0) { // The AMC/renewal invoice line hangs off a real module so it flows through diff --git a/apps/hq/test/reminder-schedule.test.ts b/apps/hq/test/reminder-schedule.test.ts new file mode 100644 index 0000000..5e31987 --- /dev/null +++ b/apps/hq/test/reminder-schedule.test.ts @@ -0,0 +1,102 @@ +// apps/hq/test/reminder-schedule.test.ts — Phase 4: dated reminder schedule (spec §4, §7) +import { describe, it, expect } from 'vitest' +import { uuidv7 } from '@sims/domain' +import { openDb, type DB } from '../src/db' +import { resolveSchedule, SCHEDULE_DEFAULTS } from '../src/repos-reminders' +import { seedIfEmpty } from '../src/seed' + +interface Row { + ruleKind: string; from: string; to?: string | null + offsets: string; subject?: string | null; body?: string | null +} + +/** Raw insert — proves a cadence change is a dated DB row, no code edit (rule 3). */ +function insertSchedule(db: DB, r: Row): void { + db.prepare( + `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(uuidv7(), r.ruleKind, r.from, r.to ?? null, r.offsets, r.subject ?? null, r.body ?? null) +} + +describe('resolveSchedule', () => { + it('falls back to the code defaults when no row exists', () => { + const db = openDb(':memory:') // unseeded — table empty + const qf = resolveSchedule(db, 'quote_followup', '2026-07-17') + expect(qf.dayOffsets).toEqual([3, 7, 14]) + expect(qf.source).toBe('default') + expect(qf.subject).toContain('{ref}') + expect(qf.body).toContain('{shareUrl}') + const inv = resolveSchedule(db, 'invoice_overdue', '2026-07-17') + expect(inv.dayOffsets).toEqual([7, 15, 30]) + expect(inv.source).toBe('default') + expect(inv.subject).toBeNull() // invoice mail text stays in reminder-templates + expect(inv.body).toBeNull() + }) + + it('returns the dated row active on today; latest effective_from wins', () => { + const db = openDb(':memory:') + insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '3,7,14' }) + insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-07-01', offsets: '2,5,9' }) + expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([2, 5, 9]) + expect(resolveSchedule(db, 'quote_followup', '2026-07-17').source).toBe('db') + expect(resolveSchedule(db, 'quote_followup', '2026-03-01').dayOffsets).toEqual([3, 7, 14]) + // a row dated in the future is not active yet + expect(resolveSchedule(db, 'quote_followup', '2025-12-31').source).toBe('default') + }) + + it('effective_to closes the window: active requires effective_to > today', () => { + const db = openDb(':memory:') + insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', to: '2026-07-01', offsets: '1,2' }) + expect(resolveSchedule(db, 'quote_followup', '2026-06-30').dayOffsets).toEqual([1, 2]) + expect(resolveSchedule(db, 'quote_followup', '2026-07-01').source).toBe('default') // boundary day excluded + }) + + it('a new dated row changes cadence with no code edit', () => { + const db = openDb(':memory:') + seedIfEmpty(db) + expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([3, 7, 14]) + insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-08-01', offsets: '5,10' }) + expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([3, 7, 14]) // unchanged before + expect(resolveSchedule(db, 'quote_followup', '2026-08-01').dayOffsets).toEqual([5, 10]) // new cadence after + }) + + it('parses messy CSV ascending and falls back on garbage offsets', () => { + const db = openDb(':memory:') + insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: ' 14, 3 ,7 ' }) + expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([3, 7, 14]) + insertSchedule(db, { ruleKind: 'invoice_overdue', from: '2026-01-01', offsets: 'a,b,-3,0' }) + const inv = resolveSchedule(db, 'invoice_overdue', '2026-07-17') + expect(inv.dayOffsets).toEqual([7, 15, 30]) // nothing usable → code default + expect(inv.source).toBe('default') + }) + + it('a cadence-only row keeps the code-constant message text', () => { + const db = openDb(':memory:') + insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '4,8' }) + const r = resolveSchedule(db, 'quote_followup', '2026-07-17') + expect(r.dayOffsets).toEqual([4, 8]) + expect(r.subject).toBe(SCHEDULE_DEFAULTS.quote_followup.subject) + expect(r.body).toBe(SCHEDULE_DEFAULTS.quote_followup.body) + }) +}) + +describe('seeded schedule rows', () => { + it('seedIfEmpty seeds one dated row per rule kind, audited, and is idempotent', () => { + const db = openDb(':memory:') + seedIfEmpty(db) + seedIfEmpty(db) // idempotent — no duplicates + const rows = db.prepare( + `SELECT rule_kind, COUNT(*) AS n FROM reminder_schedule GROUP BY rule_kind ORDER BY rule_kind`, + ).all() as { rule_kind: string; n: number }[] + expect(rows).toEqual([ + { rule_kind: 'invoice_overdue', n: 1 }, + { rule_kind: 'quote_followup', n: 1 }, + ]) + expect(resolveSchedule(db, 'quote_followup', '2026-07-17').source).toBe('db') + expect(resolveSchedule(db, 'invoice_overdue', '2026-07-17').source).toBe('db') + const audits = db.prepare( + `SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder_schedule'`, + ).get() as { n: number } + expect(audits.n).toBe(2) + }) +})