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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 52420e072c
commit dc3d651b08

@ -144,6 +144,12 @@ CREATE TABLE IF NOT EXISTS reminder (
error TEXT, created_at TEXT NOT NULL, sent_at TEXT, 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 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 ( CREATE TABLE IF NOT EXISTS aws_usage (
id TEXT PRIMARY KEY, client_id TEXT NOT NULL, id TEXT PRIMARY KEY, client_id TEXT NOT NULL,
month TEXT NOT NULL, -- 'YYYY-MM' month TEXT NOT NULL, -- 'YYYY-MM'

@ -109,6 +109,70 @@ export function dismissReminder(db: DB, userId: string, id: string): Reminder {
return setReminderStatus(db, userId, id, 'dismissed') 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) ---------- // ---------- settings helpers (shared by scheduler + bounce poller) ----------
export function getSetting(db: DB, key: string): string | null { export function getSetting(db: DB, key: string): string | null {

@ -1,8 +1,10 @@
import { randomBytes } from 'node:crypto' import { randomBytes } from 'node:crypto'
import { uuidv7 } from '@sims/domain'
import { createStaff } from './auth' import { createStaff } from './auth'
import { writeAudit } from './audit' import { writeAudit } from './audit'
import type { DB } from './db' import type { DB } from './db'
import { createModule } from './repos-modules' import { createModule } from './repos-modules'
import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders'
/** First-boot seed: owner login, company settings placeholders, GST18 tax class. */ /** 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 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) 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 } const amc = db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get() as { n: number }
if (amc.n === 0) { if (amc.n === 0) {
// The AMC/renewal invoice line hangs off a real module so it flows through // The AMC/renewal invoice line hangs off a real module so it flows through

@ -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)
})
})
Loading…
Cancel
Save