You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
356 lines
16 KiB
TypeScript
356 lines
16 KiB
TypeScript
import { uuidv7 } from '@sims/domain'
|
|
import { ownerScope } from './auth'
|
|
import { writeAudit } from './audit'
|
|
import type { DB } from './db'
|
|
import {
|
|
QUOTE_FOLLOWUP_DEFAULT_BODY, QUOTE_FOLLOWUP_DEFAULT_SUBJECT, 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 … ON CONFLICT DO NOTHING, 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 async function getReminder(db: DB, id: string): Promise<Reminder | null> {
|
|
const row = await db.get<ReminderRow>(`SELECT * FROM reminder WHERE id=?`, id)
|
|
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 async function upsertReminder(db: DB, input: UpsertReminderInput): Promise<{ id: string; created: boolean }> {
|
|
const id = uuidv7()
|
|
const now = input.now ?? new Date().toISOString()
|
|
const res = await db.run(
|
|
// Portability quirk: ON CONFLICT DO NOTHING is SQLite/Postgres dialect (standard SQL has MERGE).
|
|
`INSERT 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)
|
|
ON CONFLICT (rule_kind, subject_id, due_period) DO NOTHING`,
|
|
id, input.ruleKind, input.subjectId, input.duePeriod, input.clientId,
|
|
input.docId ?? null, input.policyApplied ?? 'manual', now,
|
|
)
|
|
if (res.changes === 1) {
|
|
await writeAudit(db, 'system', 'create', 'reminder', id, undefined, {
|
|
ruleKind: input.ruleKind, subjectId: input.subjectId, duePeriod: input.duePeriod,
|
|
})
|
|
return { id, created: true }
|
|
}
|
|
const existing = (await db.get<{ id: string }>(
|
|
`SELECT id FROM reminder WHERE rule_kind=? AND subject_id=? AND due_period=?`,
|
|
input.ruleKind, input.subjectId, input.duePeriod,
|
|
))!
|
|
return { id: existing.id, created: false }
|
|
}
|
|
|
|
export interface ReminderFilter { status?: ReminderStatus; ruleKind?: ReminderRuleKind; clientId?: string }
|
|
|
|
export async function listReminders(db: DB, filter: ReminderFilter = {}): Promise<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 (await db.all<ReminderRow>(sql, ...args)).map(toReminder)
|
|
}
|
|
|
|
/** Human labels for queue rows — exhaustive so a new rule kind cannot ship unlabelled. */
|
|
export const RULE_LABELS: Record<ReminderRuleKind, string> = {
|
|
invoice_overdue: 'Invoice overdue', renewal_due: 'Renewal due', amc_expiring: 'AMC expiring',
|
|
follow_up: 'Follow-up', recurring_generated: 'Recurring invoice', email_bounced: 'Email bounced',
|
|
quote_followup: 'Quote follow-up',
|
|
}
|
|
|
|
export interface QueueItem extends Reminder {
|
|
clientName: string
|
|
docNo: string | null
|
|
/** Derived owner (spec A3): doc_id → document.created_by; null when the reminder has no document. */
|
|
ownerId: string | null
|
|
/** Queue row label, e.g. 'Quote follow-up (d7)'. */
|
|
label: string
|
|
}
|
|
|
|
export interface QueueOpts {
|
|
/** From res.locals.staff — never the request body. Staff are forced to their own rows. */
|
|
viewerRole?: string; viewerId?: string
|
|
/** Narrow to one owner — managerial viewers only (ignored for staff via ownerScope). */
|
|
ownerId?: string
|
|
/** Narrow to one status; default is the working set queued|failed. */
|
|
status?: ReminderStatus
|
|
page?: number; pageSize?: number
|
|
}
|
|
|
|
export interface QueuePage { rows: QueueItem[]; total: number; page: number; pageSize: number }
|
|
|
|
const QUEUE_PAGE_SIZE = 50
|
|
const QUEUE_MAX_PAGE_SIZE = 200
|
|
|
|
interface QueueRow extends ReminderRow { client_name: string | null; doc_no: string | null; owner_id: string | null }
|
|
|
|
/** The manual queue: everything awaiting a human — queued items and failed sends.
|
|
* Paginated with an honest total (rule 8); owner scope derives via
|
|
* doc_id → document.created_by (spec A3 — no owner column on reminder). */
|
|
export async function listQueue(db: DB, opts: QueueOpts = {}): Promise<QueuePage> {
|
|
const page = Math.max(opts.page ?? 1, 1)
|
|
const pageSize = Math.min(Math.max(opts.pageSize ?? QUEUE_PAGE_SIZE, 1), QUEUE_MAX_PAGE_SIZE)
|
|
const scoped = opts.viewerRole !== undefined && opts.viewerId !== undefined
|
|
? ownerScope({ id: opts.viewerId, role: opts.viewerRole }, opts.ownerId)
|
|
: opts.ownerId
|
|
let where = `r.status IN ('queued','failed')`
|
|
const args: unknown[] = []
|
|
if (opts.status !== undefined) { where = `r.status = ?`; args.push(opts.status) }
|
|
// Owner scope derives via doc_id → document.created_by. Rows with NO document
|
|
// (renewal_due, amc_expiring, follow_up, email_bounced) have no derived owner —
|
|
// they are shared work and stay visible to every viewer, staff included.
|
|
if (scoped !== undefined) { where += ` AND (d.created_by = ? OR r.doc_id IS NULL)`; args.push(scoped) }
|
|
const total = (await db.get<{ n: number }>(
|
|
`SELECT COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id WHERE ${where}`,
|
|
...args,
|
|
))!.n
|
|
const rows = await db.all<QueueRow>(
|
|
`SELECT r.*, c.name AS client_name, d.doc_no, d.created_by AS owner_id
|
|
FROM reminder r
|
|
LEFT JOIN client c ON c.id = r.client_id
|
|
LEFT JOIN document d ON d.id = r.doc_id
|
|
WHERE ${where}
|
|
ORDER BY r.id DESC
|
|
LIMIT ? OFFSET ?`,
|
|
...args, pageSize, (page - 1) * pageSize,
|
|
)
|
|
return {
|
|
rows: rows.map((r) => ({
|
|
...toReminder(r),
|
|
clientName: r.client_name ?? '',
|
|
docNo: r.doc_no,
|
|
ownerId: r.owner_id,
|
|
label: r.rule_kind === 'quote_followup'
|
|
? `${RULE_LABELS.quote_followup} (${r.due_period})`
|
|
: RULE_LABELS[r.rule_kind as ReminderRuleKind] ?? r.rule_kind,
|
|
})),
|
|
total, page, pageSize,
|
|
}
|
|
}
|
|
|
|
/** Queue totals (queued/failed) under the same owner scope listQueue applies. */
|
|
export async function queueCounts(db: DB, ownerId?: string): Promise<{ queued: number; failed: number }> {
|
|
const rows = await db.all<{ status: string; n: number }>(
|
|
`SELECT r.status, COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id
|
|
WHERE r.status IN ('queued','failed')${ownerId !== undefined ? ` AND (d.created_by = ? OR r.doc_id IS NULL)` : ''}
|
|
GROUP BY r.status`,
|
|
...(ownerId !== undefined ? [ownerId] : []),
|
|
)
|
|
const by = new Map(rows.map((r) => [r.status, r.n]))
|
|
return { queued: by.get('queued') ?? 0, failed: by.get('failed') ?? 0 }
|
|
}
|
|
|
|
export async function setReminderStatus(
|
|
db: DB, userId: string, id: string, status: ReminderStatus,
|
|
opts: { error?: string | null; sentAt?: string | null } = {},
|
|
): Promise<Reminder> {
|
|
const before = await getReminder(db, id)
|
|
if (before === null) throw new Error('Reminder not found')
|
|
await db.run(
|
|
`UPDATE reminder SET status=?, error=?, sent_at=? WHERE id=?`,
|
|
status,
|
|
opts.error !== undefined ? opts.error : before.error,
|
|
opts.sentAt !== undefined ? opts.sentAt : before.sentAt,
|
|
id,
|
|
)
|
|
const after = (await getReminder(db, id))!
|
|
await writeAudit(db, userId, 'update', 'reminder', id, { status: before.status }, { status: after.status, error: after.error })
|
|
return after
|
|
}
|
|
|
|
export async function dismissReminder(db: DB, userId: string, id: string): Promise<Reminder> {
|
|
const before = await 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')
|
|
}
|
|
|
|
/**
|
|
* STOP cleanup (spec §7): a quote that is accepted/lost/converted stops chasing.
|
|
* Dismisses every still-open (queued|failed) quote_followup for the quote — one
|
|
* setReminderStatus per row so each dismissal is audited (F14), never a bulk UPDATE.
|
|
* Callers (markStatus / convertDocument) run this inside their own transaction.
|
|
*/
|
|
export async function dismissQuoteFollowups(db: DB, userId: string, quoteId: string): Promise<number> {
|
|
const rows = await db.all<{ id: string }>(
|
|
`SELECT id FROM reminder
|
|
WHERE rule_kind='quote_followup' AND subject_id=? AND status IN ('queued','failed')`,
|
|
quoteId,
|
|
)
|
|
for (const r of rows) await setReminderStatus(db, userId, r.id, 'dismissed')
|
|
return rows.length
|
|
}
|
|
|
|
// ---------- 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: QUOTE_FOLLOWUP_DEFAULT_SUBJECT,
|
|
body: QUOTE_FOLLOWUP_DEFAULT_BODY,
|
|
},
|
|
// 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.
|
|
* Lenient — invalid tokens are dropped. Only for READING stored rows, where a
|
|
* bad token must not silence the whole cadence (resolveSchedule falls back). */
|
|
export 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)
|
|
}
|
|
|
|
/** Strict variant for NEW input (insertSchedule): any token that is not a positive
|
|
* integer rejects the whole CSV — '3,7,I4' must error, never silently save as
|
|
* '3,7' and drop a follow-up from the cadence. Spaces and dupes still normalize. */
|
|
export function parseDayOffsetsStrict(csv: string): number[] {
|
|
if (csv.trim() === '') throw new Error('dayOffsets must contain at least one positive integer')
|
|
const days = csv.split(',').map((s) => {
|
|
const n = Number(s.trim())
|
|
if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid day offset '${s.trim()}' — use positive integers like 3,7,14`)
|
|
return n
|
|
})
|
|
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 async function resolveSchedule(db: DB, ruleKind: ScheduleRuleKind, today: string): Promise<ResolvedSchedule> {
|
|
const fallback = SCHEDULE_DEFAULTS[ruleKind]
|
|
const row = await db.get<{ day_offsets: string; subject: string | null; body: string | null }>(
|
|
`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, id DESC LIMIT 1`, // id DESC breaks effective_from ties deterministically (UUIDv7 is time-ordered → latest insert wins on any engine)
|
|
ruleKind, today, today,
|
|
)
|
|
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' }
|
|
}
|
|
|
|
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 async function listSchedules(db: DB): Promise<ScheduleRow[]> {
|
|
const rows = await db.all<{ id: string; rule_kind: string; effective_from: string; effective_to: string | null; day_offsets: string; subject: string | null; body: string | null }>(
|
|
`SELECT id, rule_kind, effective_from, effective_to, day_offsets, subject, body
|
|
FROM reminder_schedule ORDER BY rule_kind, effective_from DESC`,
|
|
)
|
|
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 async function insertSchedule(db: DB, userId: string, input: {
|
|
ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string
|
|
}): Promise<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 = parseDayOffsetsStrict(input.dayOffsets)
|
|
const id = uuidv7()
|
|
const csv = offsets.join(',')
|
|
await db.transaction(async () => {
|
|
await db.run(
|
|
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
|
|
VALUES (?, ?, ?, NULL, ?, ?, ?)`,
|
|
id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null,
|
|
)
|
|
await writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, {
|
|
ruleKind: input.ruleKind, effectiveFrom: input.effectiveFrom, dayOffsets: csv,
|
|
})
|
|
})
|
|
return (await listSchedules(db)).find((r) => r.id === id)!
|
|
}
|
|
|
|
// ---------- settings helpers (shared by scheduler + bounce poller) ----------
|
|
|
|
export async function getSetting(db: DB, key: string): Promise<string | null> {
|
|
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key=?`, key)
|
|
return row === undefined ? null : row.value
|
|
}
|
|
|
|
export async function setSetting(db: DB, userId: string, key: string, value: string): Promise<void> {
|
|
const before = await getSetting(db, key)
|
|
await db.run(
|
|
// 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`,
|
|
key, value,
|
|
)
|
|
await writeAudit(db, userId, before === null ? 'create' : 'update', 'setting', key, before === null ? undefined : { value: before }, { value })
|
|
}
|
|
|
|
export async function getNumberSetting(db: DB, key: string, fallback: number): Promise<number> {
|
|
const raw = await getSetting(db, key)
|
|
if (raw === null) return fallback
|
|
const n = Number(raw)
|
|
return Number.isFinite(n) ? n : fallback
|
|
}
|