import { uuidv7 } from '@sims/domain' import { writeAudit } from './audit' import type { DB } from './db' import type { ReminderRuleKind } from './reminder-templates' /** Reminder rows + settings helpers (D12 plain-repo pattern). The UNIQUE key * (rule_kind, subject_id, due_period) is the whole idempotency story — every * create goes through INSERT OR IGNORE, so catch-up after downtime is safe. */ export type ReminderStatus = 'queued' | 'sent' | 'failed' | 'dismissed' export interface Reminder { id: string; ruleKind: ReminderRuleKind; subjectId: string; duePeriod: string clientId: string; docId: string | null; status: ReminderStatus policyApplied: 'auto' | 'manual'; error: string | null; createdAt: string; sentAt: string | null } interface ReminderRow { id: string; rule_kind: string; subject_id: string; due_period: string; client_id: string doc_id: string | null; status: string; policy_applied: string; error: string | null created_at: string; sent_at: string | null } function toReminder(r: ReminderRow): Reminder { return { id: r.id, ruleKind: r.rule_kind as ReminderRuleKind, subjectId: r.subject_id, duePeriod: r.due_period, clientId: r.client_id, docId: r.doc_id, status: r.status as ReminderStatus, policyApplied: r.policy_applied as 'auto' | 'manual', error: r.error, createdAt: r.created_at, sentAt: r.sent_at, } } export function getReminder(db: DB, id: string): Reminder | null { const row = db.prepare(`SELECT * FROM reminder WHERE id=?`).get(id) as ReminderRow | undefined return row === undefined ? null : toReminder(row) } export interface UpsertReminderInput { ruleKind: ReminderRuleKind; subjectId: string; duePeriod: string; clientId: string docId?: string | null; policyApplied?: 'auto' | 'manual'; now?: string } /** Create the reminder for a (rule, subject, period) exactly once. Returns the * existing row's id with created:false when the key is already present. */ export function upsertReminder(db: DB, input: UpsertReminderInput): { id: string; created: boolean } { const id = uuidv7() const now = input.now ?? new Date().toISOString() const res = db.prepare( // Portability quirk: INSERT OR IGNORE is SQLite/Postgres dialect (standard SQL has MERGE). `INSERT OR IGNORE INTO reminder (id, rule_kind, subject_id, due_period, client_id, doc_id, status, policy_applied, error, created_at, sent_at) VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, NULL, ?, NULL)`, ).run( id, input.ruleKind, input.subjectId, input.duePeriod, input.clientId, input.docId ?? null, input.policyApplied ?? 'manual', now, ) if (res.changes === 1) { writeAudit(db, 'system', 'create', 'reminder', id, undefined, { ruleKind: input.ruleKind, subjectId: input.subjectId, duePeriod: input.duePeriod, }) return { id, created: true } } const existing = db.prepare( `SELECT id FROM reminder WHERE rule_kind=? AND subject_id=? AND due_period=?`, ).get(input.ruleKind, input.subjectId, input.duePeriod) as { id: string } return { id: existing.id, created: false } } export interface ReminderFilter { status?: ReminderStatus; ruleKind?: ReminderRuleKind; clientId?: string } export function listReminders(db: DB, filter: ReminderFilter = {}): Reminder[] { let sql = `SELECT * FROM reminder WHERE 1=1` const args: unknown[] = [] if (filter.status !== undefined) { sql += ` AND status=?`; args.push(filter.status) } if (filter.ruleKind !== undefined) { sql += ` AND rule_kind=?`; args.push(filter.ruleKind) } if (filter.clientId !== undefined) { sql += ` AND client_id=?`; args.push(filter.clientId) } sql += ` ORDER BY id DESC` return (db.prepare(sql).all(...args) as ReminderRow[]).map(toReminder) } /** The manual queue: everything awaiting a human — queued items and failed sends. */ export function listQueue(db: DB): Reminder[] { return (db.prepare( `SELECT * FROM reminder WHERE status IN ('queued','failed') ORDER BY id DESC`, ).all() as ReminderRow[]).map(toReminder) } export function setReminderStatus( db: DB, userId: string, id: string, status: ReminderStatus, opts: { error?: string | null; sentAt?: string | null } = {}, ): Reminder { const before = getReminder(db, id) if (before === null) throw new Error('Reminder not found') db.prepare(`UPDATE reminder SET status=?, error=?, sent_at=? WHERE id=?`).run( status, opts.error !== undefined ? opts.error : before.error, opts.sentAt !== undefined ? opts.sentAt : before.sentAt, id, ) const after = getReminder(db, id)! writeAudit(db, userId, 'update', 'reminder', id, { status: before.status }, { status: after.status, error: after.error }) return after } export function dismissReminder(db: DB, userId: string, id: string): Reminder { const before = getReminder(db, id) if (before === null) throw new Error('Reminder not found') if (before.status === 'sent') throw new Error('Cannot dismiss a reminder that was already sent') 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 { const row = db.prepare(`SELECT value FROM setting WHERE key=?`).get(key) as { value: string } | undefined return row === undefined ? null : row.value } export function setSetting(db: DB, userId: string, key: string, value: string): void { const before = getSetting(db, key) db.prepare( // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). `INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, ).run(key, value) writeAudit(db, userId, before === null ? 'create' : 'update', 'setting', key, before === null ? undefined : { value: before }, { value }) } export function getNumberSetting(db: DB, key: string, fallback: number): number { const raw = getSetting(db, key) if (raw === null) return fallback const n = Number(raw) return Number.isFinite(n) ? n : fallback }