From aa05060181fd8162a0834aeee5ed7dae2446140a Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 12:23:38 +0530 Subject: [PATCH] feat(settings): reminder day-settings + append-only dated schedule endpoints (owner, audited) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq/src/api.ts | 44 ++++++++++++++++++++++++- apps/hq/src/repos-reminders.ts | 43 +++++++++++++++++++++++- apps/hq/test/settings-reminders.test.ts | 36 ++++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 apps/hq/test/settings-reminders.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index ebe2136..e326632 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -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() }) diff --git a/apps/hq/src/repos-reminders.ts b/apps/hq/src/repos-reminders.ts index f001c10..99e12b4 100644 --- a/apps/hq/src/repos-reminders.ts +++ b/apps/hq/src/repos-reminders.ts @@ -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 { diff --git a/apps/hq/test/settings-reminders.test.ts b/apps/hq/test/settings-reminders.test.ts new file mode 100644 index 0000000..dc8e3c1 --- /dev/null +++ b/apps/hq/test/settings-reminders.test.ts @@ -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() + }) +})