feat(hq): reminder templates and idempotent reminder repo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
868fe17a5d
commit
a5f6b6fdaf
@ -0,0 +1,72 @@
|
|||||||
|
import { formatINR } from '@sims/domain'
|
||||||
|
|
||||||
|
/** Templated reminder emails — one place so WhatsApp can reuse them later (spec §7). */
|
||||||
|
|
||||||
|
export type ReminderRuleKind =
|
||||||
|
| 'invoice_overdue' | 'renewal_due' | 'amc_expiring'
|
||||||
|
| 'follow_up' | 'recurring_generated' | 'email_bounced'
|
||||||
|
|
||||||
|
export interface ReminderContext {
|
||||||
|
clientName: string; companyName: string
|
||||||
|
docNo?: string; amountPaise?: number; dueDate?: string
|
||||||
|
daysOverdue?: number; coverage?: string; period?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReminderMail { subject: string; bodyText: string }
|
||||||
|
|
||||||
|
const signOff = (ctx: ReminderContext): string => `Warm regards,\n${ctx.companyName}`
|
||||||
|
|
||||||
|
export function reminderEmail(kind: ReminderRuleKind, ctx: ReminderContext): ReminderMail {
|
||||||
|
switch (kind) {
|
||||||
|
case 'invoice_overdue': {
|
||||||
|
const amt = ctx.amountPaise !== undefined ? formatINR(ctx.amountPaise) : ''
|
||||||
|
return {
|
||||||
|
subject: `Payment reminder — Invoice ${ctx.docNo} (${ctx.companyName})`,
|
||||||
|
bodyText:
|
||||||
|
`Dear ${ctx.clientName},\n\n`
|
||||||
|
+ `This is a gentle reminder that Invoice ${ctx.docNo}${amt !== '' ? ` for ${amt}` : ''} is outstanding`
|
||||||
|
+ (ctx.daysOverdue !== undefined ? ` and is now ${ctx.daysOverdue} day(s) past due` : '')
|
||||||
|
+ `. We would be grateful if you could arrange payment at your earliest convenience.\n\n`
|
||||||
|
+ `If payment has already been made, kindly ignore this message.\n\n`
|
||||||
|
+ signOff(ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'recurring_generated': {
|
||||||
|
const amt = ctx.amountPaise !== undefined ? formatINR(ctx.amountPaise) : ''
|
||||||
|
const forPeriod = ctx.period !== undefined ? ` for ${ctx.period}` : ''
|
||||||
|
return {
|
||||||
|
subject: `Invoice ${ctx.docNo}${forPeriod} (${ctx.companyName})`,
|
||||||
|
bodyText:
|
||||||
|
`Dear ${ctx.clientName},\n\n`
|
||||||
|
+ `Please find attached Invoice ${ctx.docNo}${forPeriod}${amt !== '' ? `, amounting to ${amt}` : ''}.\n\n`
|
||||||
|
+ `Kindly arrange payment as per the agreed terms. Thank you for your continued association.\n\n`
|
||||||
|
+ signOff(ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'renewal_due':
|
||||||
|
return {
|
||||||
|
subject: `Subscription renewal reminder (${ctx.companyName})`,
|
||||||
|
bodyText:
|
||||||
|
`Dear ${ctx.clientName},\n\n`
|
||||||
|
+ `Your subscription is due for renewal${ctx.dueDate !== undefined ? ` on ${ctx.dueDate}` : ' shortly'}. `
|
||||||
|
+ `Please let us know if you would like us to raise the renewal invoice.\n\n`
|
||||||
|
+ `We value your continued association and look forward to serving you.\n\n`
|
||||||
|
+ signOff(ctx),
|
||||||
|
}
|
||||||
|
case 'amc_expiring':
|
||||||
|
return {
|
||||||
|
subject: `AMC renewal reminder${ctx.coverage !== undefined && ctx.coverage !== '' ? ` — ${ctx.coverage}` : ''} (${ctx.companyName})`,
|
||||||
|
bodyText:
|
||||||
|
`Dear ${ctx.clientName},\n\n`
|
||||||
|
+ `Your Annual Maintenance Contract${ctx.coverage !== undefined && ctx.coverage !== '' ? ` (${ctx.coverage})` : ''} `
|
||||||
|
+ `is due to expire${ctx.dueDate !== undefined ? ` on ${ctx.dueDate}` : ' soon'}. `
|
||||||
|
+ `To ensure uninterrupted support, please confirm the renewal at your convenience.\n\n`
|
||||||
|
+ signOff(ctx),
|
||||||
|
}
|
||||||
|
case 'follow_up':
|
||||||
|
case 'email_bounced':
|
||||||
|
// Internal dashboard items — never emailed to the client. A neutral value
|
||||||
|
// exists so callers can't crash; repos-reminders refuses to send these kinds.
|
||||||
|
return { subject: '(internal reminder)', bodyText: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { openDb } from '../src/db'
|
||||||
|
import { reminderEmail } from '../src/reminder-templates'
|
||||||
|
import {
|
||||||
|
upsertReminder, listQueue, dismissReminder, setReminderStatus, getReminder,
|
||||||
|
getNumberSetting, setSetting,
|
||||||
|
} from '../src/repos-reminders'
|
||||||
|
|
||||||
|
describe('reminder templates', () => {
|
||||||
|
it('renders an overdue dunning email with the amount and a polite tone', () => {
|
||||||
|
const mail = reminderEmail('invoice_overdue', {
|
||||||
|
clientName: 'Malabar Stores', companyName: 'Tecnostac',
|
||||||
|
docNo: 'INV/26-27-0007', amountPaise: 11_800_00, daysOverdue: 12,
|
||||||
|
})
|
||||||
|
expect(mail.subject).toContain('INV/26-27-0007')
|
||||||
|
expect(mail.bodyText).toContain('₹11,800.00')
|
||||||
|
expect(mail.bodyText).toContain('12 day')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reminder repo', () => {
|
||||||
|
it('is idempotent on (rule_kind, subject_id, due_period)', () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
const a = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv1', duePeriod: '2026-07', clientId: 'c1', docId: 'inv1', now: '2026-07-10T00:00:00Z' })
|
||||||
|
expect(a.created).toBe(true)
|
||||||
|
const b = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv1', duePeriod: '2026-07', clientId: 'c1', docId: 'inv1', now: '2026-07-10T06:00:00Z' })
|
||||||
|
expect(b.created).toBe(false)
|
||||||
|
expect(b.id).toBe(a.id) // same row returned, not a duplicate
|
||||||
|
expect(listQueue(db)).toHaveLength(1)
|
||||||
|
expect(db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder'`).get()).toMatchObject({ n: 1 })
|
||||||
|
})
|
||||||
|
it('transitions and dismisses, refusing to dismiss a sent reminder', () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
const { id } = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: 'c1', now: '2026-07-10T00:00:00Z' })
|
||||||
|
const dismissed = dismissReminder(db, 'u1', id)
|
||||||
|
expect(dismissed.status).toBe('dismissed')
|
||||||
|
const { id: id2 } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv2', duePeriod: '2026-07', clientId: 'c1', docId: 'inv2', now: '2026-07-10T00:00:00Z' })
|
||||||
|
setReminderStatus(db, 'u1', id2, 'sent', { sentAt: '2026-07-10T09:00:00Z' })
|
||||||
|
expect(getReminder(db, id2)!.status).toBe('sent')
|
||||||
|
expect(() => dismissReminder(db, 'u1', id2)).toThrow(/sent/i)
|
||||||
|
})
|
||||||
|
it('reads number settings with a fallback', () => {
|
||||||
|
const db = openDb(':memory:')
|
||||||
|
expect(getNumberSetting(db, 'reminders.overdue_days', 7)).toBe(7)
|
||||||
|
setSetting(db, 'u1', 'reminders.overdue_days', '10')
|
||||||
|
expect(getNumberSetting(db, 'reminders.overdue_days', 7)).toBe(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue