feat(settings): reminder day-settings + append-only dated schedule endpoints (owner, audited)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 4e2b71cf9f
commit aa05060181

@ -40,7 +40,7 @@ import {
listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch,
} from './repos-interactions'
import {
dismissReminder, getReminder, listQueue, setSetting,
dismissReminder, getNumberSetting, getReminder, insertSchedule, listQueue, listSchedules, setSetting,
type QueueOpts, type ReminderStatus,
} from './repos-reminders'
import { sendReminder, reminderContext, type SendReminderDeps } from './send-reminder'
@ -950,6 +950,10 @@ export function apiRouter(
})
// ---------- company profile (owner-editable; PDFs read these live) ----------
// D18: the composer autofills invoice due dates from this (issue also stamps with it).
r.get('/settings/billing', requireAuth, (_req, res) => {
res.json({ ok: true, paymentTermsDays: getNumberSetting(db, 'billing.payment_terms_days', 15) })
})
r.get('/settings/company', requireAuth, (_req, res) => {
res.json({ ok: true, company: companySettings() })
})
@ -975,6 +979,44 @@ export function apiRouter(
}
})
// ---------- reminder cadences (owner; dated rows are append-only — rule 3) ----------
r.get('/settings/reminders', requireAuth, requireOwner, (_req, res) => {
const schedules = listSchedules(db)
res.json({
ok: true,
overdueDays: getNumberSetting(db, 'reminders.overdue_days', 7),
renewalDays: getNumberSetting(db, 'reminders.renewal_days', 15),
schedules, total: schedules.length,
})
})
r.put('/settings/reminders', requireAuth, requireOwner, (req, res) => {
const body = req.body as { overdueDays?: unknown; renewalDays?: unknown }
try {
for (const [field, key] of [['overdueDays', 'reminders.overdue_days'], ['renewalDays', 'reminders.renewal_days']] as const) {
const v = body[field]
if (v === undefined) continue
if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) throw new Error(`${field} must be a positive integer`)
setSetting(db, staffId(res), key, String(v))
}
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/settings/schedules', requireAuth, requireOwner, (req, res) => {
const body = req.body as { ruleKind?: string; effectiveFrom?: string; dayOffsets?: string; subject?: string; body?: string }
try {
const schedule = insertSchedule(db, staffId(res), {
ruleKind: body.ruleKind ?? '', effectiveFrom: body.effectiveFrom ?? '', dayOffsets: body.dayOffsets ?? '',
...(body.subject !== undefined && body.subject !== '' ? { subject: body.subject } : {}),
...(body.body !== undefined && body.body !== '' ? { body: body.body } : {}),
})
res.json({ ok: true, schedule })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- document template (company.* + template.* letterhead data) ----------
r.get('/settings/template', requireAuth, (_req, res) => {
res.json({ ok: true, settings: companySettings() })

@ -235,7 +235,7 @@ export const SCHEDULE_DEFAULTS: Record<
}
/** Parse a day_offsets CSV ('3,7,14') → ascending unique positive integers. */
function parseDayOffsets(csv: string): number[] {
export function parseDayOffsets(csv: string): number[] {
const days = csv.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isInteger(n) && n > 0)
@ -267,6 +267,47 @@ export function resolveSchedule(db: DB, ruleKind: ScheduleRuleKind, today: strin
return { ruleKind, dayOffsets: [...fallback.dayOffsets], subject: fallback.subject, body: fallback.body, source: 'default' }
}
export interface ScheduleRow {
id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null
dayOffsets: string; subject: string | null; body: string | null
}
const SCHEDULE_KINDS = Object.keys(SCHEDULE_DEFAULTS)
/** All dated schedule rows, newest first within each rule kind (bounded set). */
export function listSchedules(db: DB): ScheduleRow[] {
const rows = db.prepare(
`SELECT id, rule_kind, effective_from, effective_to, day_offsets, subject, body
FROM reminder_schedule ORDER BY rule_kind, effective_from DESC`,
).all() as { id: string; rule_kind: string; effective_from: string; effective_to: string | null; day_offsets: string; subject: string | null; body: string | null }[]
return rows.map((r) => ({
id: r.id, ruleKind: r.rule_kind, effectiveFrom: r.effective_from, effectiveTo: r.effective_to,
dayOffsets: r.day_offsets, subject: r.subject, body: r.body,
}))
}
/** Append a NEW dated cadence row (rule 3: config change = new row, never an edit). */
export function insertSchedule(db: DB, userId: string, input: {
ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string
}): ScheduleRow {
if (!SCHEDULE_KINDS.includes(input.ruleKind)) throw new Error(`Unknown rule kind: ${input.ruleKind}`)
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.effectiveFrom)) throw new Error('effectiveFrom must be YYYY-MM-DD')
const offsets = parseDayOffsets(input.dayOffsets)
if (offsets.length === 0) throw new Error('dayOffsets must contain at least one positive integer')
const id = uuidv7()
const csv = offsets.join(',')
db.transaction(() => {
db.prepare(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES (?, ?, ?, NULL, ?, ?, ?)`,
).run(id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null)
writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, {
ruleKind: input.ruleKind, effectiveFrom: input.effectiveFrom, dayOffsets: csv,
})
})()
return listSchedules(db).find((r) => r.id === id)!
}
// ---------- settings helpers (shared by scheduler + bounce poller) ----------
export function getSetting(db: DB, key: string): string | null {

@ -0,0 +1,36 @@
// apps/hq/test/settings-reminders.test.ts — Task 3: reminder settings + dated schedule inserts
import { describe, expect, it } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { insertSchedule, listSchedules, resolveSchedule } from '../src/repos-reminders'
const fresh = () => { const db = openDb(':memory:'); seedIfEmpty(db); return db }
describe('schedule settings', () => {
it('lists the two seeded open-ended rows', () => {
const rows = listSchedules(fresh())
expect(rows.map((r) => r.ruleKind).sort()).toEqual(['invoice_overdue', 'quote_followup'])
})
it('insertSchedule appends a dated row that resolveSchedule picks from its date', () => {
const db = fresh()
const row = insertSchedule(db, 'u1', {
ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: ' 2, 5,5, 9 ',
})
expect(row.dayOffsets).toBe('2,5,9') // normalized: trimmed, deduped, sorted
expect(resolveSchedule(db, 'quote_followup', '2026-08-02').dayOffsets).toEqual([2, 5, 9])
expect(resolveSchedule(db, 'quote_followup', '2026-07-20').dayOffsets).toEqual([3, 7, 14])
expect(listSchedules(db)).toHaveLength(3) // append-only: old row still there
})
it('rejects bad input', () => {
const db = fresh()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).toThrow()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).toThrow()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: 'x,-2' })).toThrow()
})
it('audits the insert', () => {
const db = fresh()
const row = insertSchedule(db, 'u1', { ruleKind: 'invoice_overdue', effectiveFrom: '2026-09-01', dayOffsets: '10,20' })
const audit = db.prepare(`SELECT * FROM audit_log WHERE entity='reminder_schedule' AND entity_id=?`).get(row.id)
expect(audit).toBeDefined()
})
})
Loading…
Cancel
Save