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
parent
52420e072c
commit
dc3d651b08
@ -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…
Reference in New Issue