feat(reminders): escalating quote follow-up (Phase 6, spec §7)

- reminder.rule_kind gains 'quote_followup': SCHEMA widened + rebuild-once
  migration via the shared rebuildTable helper (UNIQUE idempotency key and
  status/policy_applied CHECKs preserved verbatim; guarded, idempotent)
- runDailyScan: new scan block over sent quotations anchored on the first
  'sent' event; dated day offsets from resolveSchedule; catch-up guard fires
  only the single highest crossed milestone; policy from setting
  quote.followup.policy (default manual); auto sends drain strictly after
  the scan writes commit, at-most-once via the unique key
- reminderContext resolves (never mints) a live share and dated subject/body;
  graceful wording when the quote has no docNo; minting happens ONLY in the
  send path with a ~60-day expiry, reusing a live share
- STOP cleanup: markStatus(accepted|lost) and convertDocument dismiss open
  quote_followup rows per-row (each audited) in the same transaction
- listQueue: paginated with honest total, owner-scoped via
  doc_id -> document.created_by, labelled rows; dashboard + /reminders wired

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent a244ddb2b0
commit c45b5692be

@ -307,7 +307,8 @@ export interface Interaction {
}
export type ReminderRuleKind =
| 'invoice_overdue' | 'renewal_due' | 'amc_expiring' | 'follow_up' | 'recurring_generated' | 'email_bounced'
| 'invoice_overdue' | 'renewal_due' | 'amc_expiring' | 'follow_up' | 'recurring_generated'
| 'email_bounced' | 'quote_followup'
export type ReminderStatus = 'queued' | 'sent' | 'failed' | 'dismissed'
export interface Reminder {
id: string; ruleKind: ReminderRuleKind; subjectId: string; duePeriod: string; clientId: string
@ -322,7 +323,9 @@ export interface DashboardView {
renewalsThisMonth: { clientModuleId: string; clientId: string; clientName: string; nextRenewal: string }[]
followUpsToday: { id: string; clientId: string; clientName: string; onDate: string; followUpOn: string; notes: string }[]
recentPayments: { id: string; clientId: string; clientName: string; receivedOn: string; amountPaise: number; mode: string }[]
queue: (Reminder & { clientName: string; docNo: string | null })[]
queue: (Reminder & { clientName: string; docNo: string | null; ownerId: string | null; label: string })[]
/** Honest queue size — `queue` is the first page only. */
queueTotal: number
totals: { overduePaise: number; queued: number; failed: number }
}

@ -10,8 +10,9 @@ const inr = (p: number) => formatINR(p)
const RULE_LABEL: Record<string, string> = {
invoice_overdue: 'Overdue invoice', 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',
}
const SENDABLE = new Set(['invoice_overdue', 'renewal_due', 'amc_expiring', 'recurring_generated'])
const SENDABLE = new Set(['invoice_overdue', 'renewal_due', 'amc_expiring', 'recurring_generated', 'quote_followup'])
const toneFor = (status: string): 'ok' | 'warn' | 'err' =>
status === 'failed' ? 'err' : status === 'sent' ? 'ok' : 'warn'
@ -49,7 +50,7 @@ export function Dashboard() {
return s === 'failed' ? 'err' : undefined
}}
rows={v.queue.map((rem) => ({
kind: RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
kind: rem.label !== '' ? rem.label : RULE_LABEL[rem.ruleKind] ?? rem.ruleKind,
client: rem.clientName,
ref: rem.docNo ?? rem.duePeriod,
status: <Badge tone={toneFor(rem.status)}>{rem.status}</Badge>,

@ -40,7 +40,7 @@ import {
} from './repos-interactions'
import {
dismissReminder, getReminder, listQueue, listReminders, setSetting,
type ReminderStatus,
type QueueOpts, type ReminderStatus,
} from './repos-reminders'
import { sendReminder, reminderContext, type SendReminderDeps } from './send-reminder'
import { reminderEmail } from './reminder-templates'
@ -648,9 +648,21 @@ export function apiRouter(
})
// ---------- reminders (manual queue) ----------
// Queue view is paginated with an honest total (rule 8) and owner-scoped: staff are
// server-forced to their own rows (derived doc_id → document.created_by, spec A3);
// owner/manager see all and may narrow via ?owner=. `?status=` keeps the flat list.
r.get('/reminders', requireAuth, (req, res) => {
const status = typeof req.query['status'] === 'string' ? req.query['status'] as ReminderStatus : undefined
res.json({ ok: true, reminders: status !== undefined ? listReminders(db, { status }) : listQueue(db) })
if (status !== undefined) { res.json({ ok: true, reminders: listReminders(db, { status }) }); return }
const viewer = res.locals['staff'] as { id: string; role: string }
const opts: QueueOpts = { viewerRole: viewer.role, viewerId: viewer.id }
if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner']
const page = Number(req.query['page'])
if (Number.isInteger(page) && page >= 1) opts.page = page
const pageSize = Number(req.query['pageSize'])
if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize
const q = listQueue(db, opts)
res.json({ ok: true, reminders: q.rows, total: q.total, page: q.page, pageSize: q.pageSize })
})
r.get('/reminders/:id/preview', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
@ -696,7 +708,7 @@ export function apiRouter(
// ---------- dashboard ----------
r.get('/dashboard', requireAuth, (_req, res) => {
const today = new Date().toISOString().slice(0, 10)
res.json({ ok: true, view: dashboardView(db, today) })
res.json({ ok: true, view: dashboardView(db, today, res.locals['staff'] as { id: string; role: string }) })
})
// ---------- payments ----------

@ -136,7 +136,7 @@ CREATE TABLE IF NOT EXISTS interaction (
CREATE TABLE IF NOT EXISTS reminder (
id TEXT PRIMARY KEY,
rule_kind TEXT NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced')),
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced','quote_followup')),
subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL,
doc_id TEXT, -- the document this reminder concerns (overdue/recurring/amc invoice); NULL otherwise
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
@ -192,6 +192,7 @@ function migrate(db: DB): void {
db.exec(`ALTER TABLE client ADD COLUMN owner_id TEXT`)
}
rebuildStaffUserRoleCheck(db)
rebuildReminderRuleKindCheck(db)
}
/**
@ -232,3 +233,31 @@ export function rebuildStaffUserRoleCheck(db: DB): void {
['id', 'email', 'display_name', 'role', 'pw_salt', 'pw_hash', 'active'],
)
}
/**
* Widen reminder.rule_kind CHECK to allow 'quote_followup' on DBs created before the
* quote-follow-up slice (spec §4). The status/policy_applied CHECKs and the
* UNIQUE (rule_kind, subject_id, due_period) idempotency key are preserved verbatim.
* Guard token: the old list ends "...'email_bounced')" the new DDL continues
* ",'quote_followup')" instead, so the fragment vanishes once rebuilt (idempotent).
*/
export function rebuildReminderRuleKindCheck(db: DB): void {
rebuildTable(
db,
'reminder',
`CREATE TABLE reminder__new (
id TEXT PRIMARY KEY,
rule_kind TEXT NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced','quote_followup')),
subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL,
doc_id TEXT, -- the document this reminder concerns (overdue/recurring/amc invoice; the quote for quote_followup); NULL otherwise
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
error TEXT, created_at TEXT NOT NULL, sent_at TEXT,
UNIQUE (rule_kind, subject_id, due_period) -- the idempotency key: at-most-once per due-period
)`,
`'email_bounced')`,
['id', 'rule_kind', 'subject_id', 'due_period', 'client_id', 'doc_id',
'status', 'policy_applied', 'error', 'created_at', 'sent_at'],
)
}

@ -4,14 +4,30 @@ import { formatINR } from '@sims/domain'
export type ReminderRuleKind =
| 'invoice_overdue' | 'renewal_due' | 'amc_expiring'
| 'follow_up' | 'recurring_generated' | 'email_bounced'
| 'follow_up' | 'recurring_generated' | 'email_bounced' | 'quote_followup'
export interface ReminderContext {
clientName: string; companyName: string
docNo?: string; amountPaise?: number; dueDate?: string
daysOverdue?: number; coverage?: string; period?: string
/** quote_followup: client-facing reference — the docNo, or 'dated YYYY-MM-DD' when unissued (never "null"). */
ref?: string
/** quote_followup: the live public share link (resolved read-only; minted only in the send path). */
shareUrl?: string
/** quote_followup: dated message text from resolveSchedule; falls back to the code constants below. */
subjectTemplate?: string
bodyTemplate?: string
}
/** Code-constant fallback text for quote_followup (spec §7) reminder_schedule rows
* override these with dated subject/body; they live here so repos-reminders can
* reuse them without an import cycle. */
export const QUOTE_FOLLOWUP_DEFAULT_SUBJECT = 'Following up on our quotation {ref} ({companyName})'
export const QUOTE_FOLLOWUP_DEFAULT_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}.'
export interface ReminderMail { subject: string; bodyText: string }
const signOff = (ctx: ReminderContext): string => `Warm regards,\n${ctx.companyName}`
@ -63,6 +79,19 @@ export function reminderEmail(kind: ReminderRuleKind, ctx: ReminderContext): Rem
+ `To ensure uninterrupted support, please confirm the renewal at your convenience.\n\n`
+ signOff(ctx),
}
case 'quote_followup': {
// Dated subject/body come from reminder_schedule via the context builder;
// placeholders are filled here so a schedule row can rewrite the copy freely.
const fill = (t: string): string => t
.replaceAll('{ref}', ctx.ref ?? '')
.replaceAll('{clientName}', ctx.clientName)
.replaceAll('{companyName}', ctx.companyName)
.replaceAll('{shareUrl}', ctx.shareUrl ?? '')
return {
subject: fill(ctx.subjectTemplate ?? QUOTE_FOLLOWUP_DEFAULT_SUBJECT),
bodyText: fill(ctx.bodyTemplate ?? QUOTE_FOLLOWUP_DEFAULT_BODY),
}
}
case 'follow_up':
case 'email_bounced':
// Internal dashboard items — never emailed to the client. A neutral value

@ -1,7 +1,8 @@
import { ownerScope } from './auth'
import type { DB } from './db'
import { outstandingPaise } from './repos-payments'
import { listOpenFollowUps } from './repos-interactions'
import { listQueue, type Reminder } from './repos-reminders'
import { listQueue, queueCounts, type QueueItem } from './repos-reminders'
import { addDaysIso } from './scheduler'
/** Read-only money view for the dashboard home (spec §3 "today's money"). */
@ -13,7 +14,9 @@ export interface DashboardView {
renewalsThisMonth: { clientModuleId: string; clientId: string; clientName: string; nextRenewal: string }[]
followUpsToday: { id: string; clientId: string; clientName: string; onDate: string; followUpOn: string; notes: string }[]
recentPayments: { id: string; clientId: string; clientName: string; receivedOn: string; amountPaise: number; mode: string }[]
queue: (Reminder & { clientName: string; docNo: string | null })[]
queue: QueueItem[]
/** Honest queue size — `queue` is the first page only (rule 8: no silent caps). */
queueTotal: number
totals: { overduePaise: number; queued: number; failed: number }
}
@ -26,7 +29,7 @@ function endOfMonth(today: string): string {
return new Date(Date.UTC(y!, m!, 0)).toISOString().slice(0, 10) // day 0 of next month = last day of this
}
export function dashboardView(db: DB, today: string): DashboardView {
export function dashboardView(db: DB, today: string, viewer?: { id: string; role: string }): DashboardView {
const overdueRows = db.prepare(
`SELECT d.id, d.doc_no, d.client_id, d.doc_date, c.name AS client_name
FROM document d JOIN client c ON c.id = d.client_id
@ -78,20 +81,15 @@ export function dashboardView(db: DB, today: string): DashboardView {
return { id: row.id, clientId: row.client_id, clientName: row.client_name, receivedOn: row.received_on, amountPaise: row.amount_paise, mode: row.mode }
})
const queue = listQueue(db).map((rem) => {
const client = db.prepare(`SELECT name FROM client WHERE id=?`).get(rem.clientId) as { name: string } | undefined
const doc = rem.docId !== null
? db.prepare(`SELECT doc_no FROM document WHERE id=?`).get(rem.docId) as { doc_no: string | null } | undefined
: undefined
return { ...rem, clientName: client?.name ?? '', docNo: doc?.doc_no ?? null }
})
// Same owner scope as GET /reminders: staff see only their own queue (spec §6e).
const queuePage = viewer !== undefined
? listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role })
: listQueue(db)
const counts = queueCounts(db, viewer !== undefined ? ownerScope(viewer) : undefined)
return {
today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments, queue,
totals: {
overduePaise,
queued: queue.filter((q) => q.status === 'queued').length,
failed: queue.filter((q) => q.status === 'failed').length,
},
today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments,
queue: queuePage.rows, queueTotal: queuePage.total,
totals: { overduePaise, queued: counts.queued, failed: counts.failed },
}
}

@ -4,6 +4,7 @@ import { writeAudit } from './audit'
import type { DB } from './db'
import { getClient } from './repos-clients'
import { getModule, priceOn, type Kind } from './repos-modules'
import { dismissQuoteFollowups } from './repos-reminders'
import { nextDocNo } from './series'
/**
@ -296,6 +297,11 @@ export function markStatus(db: DB, userId: string, docId: string, status: 'sent'
db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId)
addEvent(db, docId, status)
writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status })
if (status === 'accepted' || status === 'lost') {
// STOP cleanup (spec §7): a decided quote stops chasing — dismiss its open
// follow-up nudges in this same transaction, one audited row each.
dismissQuoteFollowups(db, userId, docId)
}
return getDocument(db, docId)!
})()
}
@ -322,6 +328,11 @@ export function convertDocument(db: DB, userId: string, docId: string, to: 'PROF
db.prepare(`UPDATE document SET status='invoiced' WHERE id=?`).run(src.id)
writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' })
}
if (src.docType === 'QUOTATION') {
// STOP cleanup (spec §7): a converted quote has moved forward — dismiss its
// open follow-up nudges in this same transaction, one audited row each.
dismissQuoteFollowups(db, userId, src.id)
}
return doc
})()
}

@ -1,7 +1,10 @@
import { uuidv7 } from '@sims/domain'
import { ownerScope } from './auth'
import { writeAudit } from './audit'
import type { DB } from './db'
import type { ReminderRuleKind } from './reminder-templates'
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
@ -78,11 +81,84 @@ export function listReminders(db: DB, filter: ReminderFilter = {}): Reminder[] {
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)
/** 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
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 function listQueue(db: DB, opts: QueueOpts = {}): 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 (scoped !== undefined) { where += ` AND d.created_by = ?`; args.push(scoped) }
const total = (db.prepare(
`SELECT COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id WHERE ${where}`,
).get(...args) as { n: number }).n
const rows = db.prepare(
`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 ?`,
).all(...args, pageSize, (page - 1) * pageSize) as QueueRow[]
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 function queueCounts(db: DB, ownerId?: string): { queued: number; failed: number } {
const rows = db.prepare(
`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 = ?` : ''}
GROUP BY r.status`,
).all(...(ownerId !== undefined ? [ownerId] : [])) as { status: string; n: number }[]
const by = new Map(rows.map((r) => [r.status, r.n]))
return { queued: by.get('queued') ?? 0, failed: by.get('failed') ?? 0 }
}
export function setReminderStatus(
@ -109,6 +185,21 @@ export function dismissReminder(db: DB, userId: string, id: string): Reminder {
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 function dismissQuoteFollowups(db: DB, userId: string, quoteId: string): number {
const rows = db.prepare(
`SELECT id FROM reminder
WHERE rule_kind='quote_followup' AND subject_id=? AND status IN ('queued','failed')`,
).all(quoteId) as { id: string }[]
for (const r of rows) 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). */
@ -130,11 +221,8 @@ export const SCHEDULE_DEFAULTS: Record<
> = {
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}.',
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 },

@ -51,6 +51,21 @@ export function listShares(db: DB, documentId: string): Share[] {
return rows.map(toShare)
}
/**
* The newest live (non-revoked, non-expired) share for a document, or null.
* Read-only safe for preview/context paths that must write nothing; the send
* path uses it to reuse an existing link instead of minting a duplicate.
*/
export function resolveLiveShare(db: DB, documentId: string): Share | null {
const row = db.prepare(
// ISO-8601 UTC strings compare lexicographically === chronologically.
`SELECT * FROM document_share
WHERE document_id=? AND revoked=0 AND (expires_at IS NULL OR expires_at > ?)
ORDER BY id DESC LIMIT 1`, // uuidv7 ids: newest first
).get(documentId, new Date().toISOString()) as ShareRow | undefined
return row === undefined ? null : toShare(row)
}
export interface MintShareOpts {
/** Days until the link expires; default 30. `null` = never expires. */
expiresDays?: number | null

@ -4,7 +4,7 @@ import { createDraft, issueDocument } from './repos-documents'
import { getClientModule } from './repos-modules'
import { outstandingPaise } from './repos-payments'
import { CADENCE_KIND, getRecurringPlan } from './repos-recurring'
import { getNumberSetting, upsertReminder } from './repos-reminders'
import { getNumberSetting, getSetting, resolveSchedule, upsertReminder } from './repos-reminders'
import { sendReminder, type SendReminderDeps } from './send-reminder'
/**
@ -155,8 +155,41 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
}
}
// --- quote_followup (escalating chase on sent quotations — spec §7) ---
// Age anchors on the FIRST 'sent' event (F15). The dated schedule (rule 3) yields
// the day offsets; send policy is a flat operational setting, default manual.
const followupSchedule = resolveSchedule(db, 'quote_followup', today)
const followupPolicy: 'auto' | 'manual' =
getSetting(db, 'quote.followup.policy') === 'auto' ? 'auto' : 'manual'
const sentQuotes = db.prepare(
`SELECT d.id, d.client_id,
(SELECT MIN(e.at_wall) FROM document_event e
WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent
FROM document d WHERE d.doc_type='QUOTATION' AND d.status='sent'`,
).all() as { id: string; client_id: string; first_sent: string | null }[]
const followupAuto: string[] = []
for (const q of sentQuotes) {
if (q.first_sent === null) continue
const age = Math.floor((Date.parse(today) - Date.parse(q.first_sent.slice(0, 10))) / 86_400_000)
const crossed = followupSchedule.dayOffsets.filter((o) => age >= o)
if (crossed.length === 0) continue
// Catch-up burst guard (F7): enqueue ONLY the single highest crossed milestone —
// a quote already past several offsets gets one nudge, never the whole ladder.
// Milestones consumed on their own day stay consumed (threshold-day bucket in the
// unique key), so daily operation still fires each interval exactly once.
const highest = crossed[crossed.length - 1]!
const up = upsertReminder(db, {
ruleKind: 'quote_followup', subjectId: q.id, duePeriod: `d${highest}`,
clientId: q.client_id, docId: q.id, policyApplied: followupPolicy, now,
})
if (up.created) {
bump(created, 'quote_followup')
if (followupPolicy === 'auto') followupAuto.push(up.id)
}
}
// --- recurring_generated (transactional generation, then async auto-send) ---
const autoQueue = generateRecurring(db, today, now, created)
const autoQueue = [...followupAuto, ...generateRecurring(db, today, now, created)]
let autoSent = 0
let autoFailed = 0
for (const reminderId of autoQueue) {

@ -5,7 +5,10 @@ import { getClient, type Client } from './repos-clients'
import { getClientModule } from './repos-modules'
import { getDocument, type Doc } from './repos-documents'
import { getAmc } from './repos-amc'
import { getReminder, setReminderStatus, type Reminder } from './repos-reminders'
import {
getReminder, getSetting, resolveSchedule, setReminderStatus, type Reminder,
} from './repos-reminders'
import { mintShare, resolveLiveShare } from './repos-shares'
import { reminderEmail, type ReminderContext } from './reminder-templates'
import { documentHtml } from './templates'
@ -23,6 +26,12 @@ export interface SendReminderDeps {
* and GET /reminders/:id/preview so the previewed email equals the sent one. */
export interface ReminderRender { ctx: ReminderContext; doc: Doc | null; client: Client }
/** Public URL for a share token: setting 'share.base_url' + /share/<token> (path-only when unset). */
function shareUrlFor(db: DB, token: string): string {
const base = getSetting(db, 'share.base_url')
return `${base !== null ? base.replace(/\/+$/, '') : ''}/share/${token}`
}
export function reminderContext(db: DB, reminder: Reminder, companyName: string): ReminderRender {
const client = getClient(db, reminder.clientId)
if (client === null) throw new Error('Client not found')
@ -34,6 +43,20 @@ export function reminderContext(db: DB, reminder: Reminder, companyName: string)
ctx.docNo = doc.docNo ?? undefined
ctx.amountPaise = doc.payablePaise
ctx.period = reminder.duePeriod
if (reminder.ruleKind === 'quote_followup') {
const today = new Date().toISOString().slice(0, 10)
const schedule = resolveSchedule(db, 'quote_followup', today) // dated message text (rule 3)
ctx.subjectTemplate = schedule.subject ?? undefined
ctx.bodyTemplate = schedule.body ?? undefined
// Quotes reach 'sent' without issuance (F10) — never render "quotation null".
ctx.ref = doc.docNo ?? `dated ${doc.docDate}`
// Resolve-only (F5): this builder is shared with GET /preview and must write
// nothing. Minting happens exclusively in sendReminder below.
const live = resolveLiveShare(db, doc.id)
ctx.shareUrl = live !== null
? shareUrlFor(db, live.token)
: '(a view link is generated when this reminder is sent)'
}
} else if (reminder.ruleKind === 'renewal_due') {
const cm = getClientModule(db, reminder.subjectId)
if (cm !== null && cm.nextRenewal !== null) ctx.dueDate = cm.nextRenewal
@ -54,6 +77,13 @@ export async function sendReminder(
}
const company = deps.company()
const companyName = company['company.name'] ?? ''
if (reminder.ruleKind === 'quote_followup' && reminder.docId !== null) {
// The ONLY place a follow-up share is minted (rule 6 / F5): reuse a live link,
// else mint with an expiry covering the escalation window — never never-expiring (F12).
if (resolveLiveShare(db, reminder.docId) === null) {
mintShare(db, userId, reminder.docId, { expiresDays: 60 })
}
}
const { ctx, doc, client } = reminderContext(db, reminder, companyName)
let attachment: { filename: string; data: Buffer } | undefined

@ -0,0 +1,294 @@
// apps/hq/test/quote-followup.test.ts — Phase 6: escalating quote follow-up (spec §7)
import { describe, it, expect } from 'vitest'
import { openDb, rebuildReminderRuleKindCheck, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient, type Client } from '../src/repos-clients'
import { createModule, setPrice, type Module } from '../src/repos-modules'
import { convertDocument, createDraft, issueDocument, markStatus, type Doc } from '../src/repos-documents'
import { saveAccount } from '../src/repos-email'
import { encrypt } from '../src/crypto'
import {
getReminder, listQueue, listReminders, setSetting, upsertReminder,
} from '../src/repos-reminders'
import { reminderContext, sendReminder, type SendReminderDeps } from '../src/send-reminder'
import { reminderEmail } from '../src/reminder-templates'
import { runDailyScan, type ScanDeps } from '../src/scheduler'
const KEY = '11'.repeat(32)
const okFetch = (async (url: string) =>
new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch
const deps: ScanDeps & SendReminderDeps = {
gmail: { f: okFetch, clientId: 'cid', clientSecret: 'sec', keyHex: KEY },
renderPdf: async () => Buffer.from('%PDF-fake'),
company: () => ({ 'company.name': 'Tecnostac' }),
now: () => '2026-07-10T09:00:00Z',
}
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m }
}
/** A QUOTATION marked sent, with the first-sent event pinned to `sentAt` for age math. */
function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Doc {
const q = createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
markStatus(db, by, q.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`).run(sentAt, q.id)
return q
}
const followups = (db: DB) => listReminders(db, { ruleKind: 'quote_followup' })
const shareCount = (db: DB): number =>
(db.prepare(`SELECT COUNT(*) AS n FROM document_share`).get() as { n: number }).n
// ---------- schema migration (rebuild-once via the shared helper) ----------
const OLD_REMINDER_DDL = `
CREATE TABLE reminder (
id TEXT PRIMARY KEY,
rule_kind TEXT NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced')),
subject_id TEXT NOT NULL, due_period TEXT NOT NULL, client_id TEXT NOT NULL,
doc_id TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
policy_applied TEXT NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
error TEXT, created_at TEXT NOT NULL, sent_at TEXT,
UNIQUE (rule_kind, subject_id, due_period)
)`
describe('reminder.rule_kind CHECK widening', () => {
it('a fresh DB accepts quote_followup directly', () => {
const db = openDb(':memory:')
const up = upsertReminder(db, { ruleKind: 'quote_followup', subjectId: 'q1', duePeriod: 'd3', clientId: 'c1', docId: 'q1', now: '2026-07-10T00:00:00Z' })
expect(up.created).toBe(true)
})
it('rebuilds an old-CHECK table preserving rows, UNIQUE key and status CHECK; idempotent', () => {
const db = openDb(':memory:')
db.exec(`DROP TABLE reminder`)
db.exec(OLD_REMINDER_DDL)
db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-old','invoice_overdue','inv1','2026-07','c1','queued','manual','2026-07-01T00:00:00Z')`,
).run()
// Old CHECK really rejects the new kind before the rebuild.
expect(() => db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-new','quote_followup','q1','d3','c1','queued','manual','2026-07-01T00:00:00Z')`,
).run()).toThrow()
rebuildReminderRuleKindCheck(db)
// Old row copied across, new kind now allowed.
expect(db.prepare(`SELECT rule_kind FROM reminder WHERE id='r-old'`).get()).toMatchObject({ rule_kind: 'invoice_overdue' })
db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-new','quote_followup','q1','d3','c1','queued','manual','2026-07-01T00:00:00Z')`,
).run()
// Idempotency key preserved verbatim.
expect(() => db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-dup','quote_followup','q1','d3','c1','queued','manual','2026-07-01T00:00:00Z')`,
).run()).toThrow()
// status CHECK preserved verbatim.
expect(() => db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
VALUES ('r-bad','quote_followup','q2','d3','c1','bogus','manual','2026-07-01T00:00:00Z')`,
).run()).toThrow()
// Re-run is a no-op (rows intact).
rebuildReminderRuleKindCheck(db)
expect((db.prepare(`SELECT COUNT(*) AS n FROM reminder`).get() as { n: number }).n).toBe(2)
})
})
// ---------- the daily scan ----------
describe('runDailyScan — quote_followup escalation', () => {
it('fires each interval at most once per quote, day by day', async () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
expect((await runDailyScan(db, deps, '2026-07-02')).created['quote_followup'] ?? 0).toBe(0) // age 1 < 3
expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup']).toBe(1) // d3
expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup'] ?? 0).toBe(0) // same day re-run
expect((await runDailyScan(db, deps, '2026-07-08')).created['quote_followup']).toBe(1) // d7
expect((await runDailyScan(db, deps, '2026-07-15')).created['quote_followup']).toBe(1) // d14
expect((await runDailyScan(db, deps, '2026-07-20')).created['quote_followup'] ?? 0).toBe(0) // ladder consumed
const rows = followups(db)
expect(rows.map((r) => r.duePeriod).sort()).toEqual(['d14', 'd3', 'd7'])
expect(rows.every((r) => r.subjectId === q.id && r.docId === q.id && r.policyApplied === 'manual')).toBe(true)
})
it('catch-up fires ONLY the single highest crossed milestone', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-06-01T09:00:00Z') // age 39 on first scan — past 3, 7 and 14
const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['quote_followup']).toBe(1)
const rows = followups(db)
expect(rows).toHaveLength(1)
expect(rows[0]!.duePeriod).toBe('d14')
})
it('ignores quotes that are not sent (draft / accepted / lost / cancelled leave the set)', async () => {
const { db, c, m } = world()
createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const q = sentQuote(db, c, m, '2026-06-01T09:00:00Z')
markStatus(db, 'u1', q.id, 'accepted')
const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['quote_followup'] ?? 0).toBe(0)
})
it('auto policy drains sends after the scan, at-most-once, minting one ~60-day share', async () => {
const { db, c, m } = world()
setSetting(db, 'u1', 'quote.followup.policy', 'auto')
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const res = await runDailyScan(db, deps, '2026-07-04') // d3
expect(res.created['quote_followup']).toBe(1)
expect(res.autoSent).toBe(1)
const row = followups(db)[0]!
expect(row.policyApplied).toBe('auto')
expect(row.status).toBe('sent')
expect(shareCount(db)).toBe(1)
const share = db.prepare(`SELECT expires_at FROM document_share`).get() as { expires_at: string | null }
expect(share.expires_at).not.toBeNull() // never-expiring links are refused (F12)
const days = (Date.parse(share.expires_at!) - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(59); expect(days).toBeLessThan(61)
// Next milestone reuses the live share instead of duplicating it.
const res2 = await runDailyScan(db, deps, '2026-07-08') // d7
expect(res2.autoSent).toBe(1)
expect(shareCount(db)).toBe(1)
// Re-run creates nothing and sends nothing (unique key → at-most-once).
const res3 = await runDailyScan(db, deps, '2026-07-08')
expect(res3.created['quote_followup'] ?? 0).toBe(0)
expect(res3.autoSent).toBe(0)
})
})
// ---------- preview / context (read-only) and send (mints) ----------
describe('quote_followup context, preview and send', () => {
it('preview writes nothing — no share minted, no audit rows', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]!
const auditBefore = (db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n
const { ctx } = reminderContext(db, rem, 'Tecnostac')
const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).toContain('Tecnostac')
expect(shareCount(db)).toBe(0) // resolve-only: preview NEVER mints
expect((db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n).toBe(auditBefore)
})
it('degrades gracefully when the quote has no docNo — never renders "quotation null"', async () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') // never issued: docNo is null (F10)
await runDailyScan(db, deps, '2026-07-04')
const { ctx } = reminderContext(db, followups(db)[0]!, 'Tecnostac')
const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).not.toContain('null')
expect(mail.bodyText).not.toContain('null')
expect(mail.subject).toContain(`dated ${q.docDate}`)
expect(mail.bodyText).toContain('Acme') // clientName substituted
})
it('uses the doc number when the quote is issued', async () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const issued = issueDocument(db, 'u1', q.id)
await runDailyScan(db, deps, '2026-07-04')
const { ctx } = reminderContext(db, followups(db)[0]!, 'Tecnostac')
expect(reminderEmail('quote_followup', ctx).subject).toContain(issued.docNo!)
})
it('manual send mints one ~60-day share and a second send reuses it', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') // d3, manual
const first = followups(db)[0]!
const out = await sendReminder(db, deps, first.id, 'u1')
expect(out).toEqual({ ok: true })
expect(shareCount(db)).toBe(1)
// The sent body carries a live share link.
const { ctx } = reminderContext(db, getReminder(db, first.id)!, 'Tecnostac')
expect(reminderEmail('quote_followup', ctx).bodyText).toContain('/share/')
await runDailyScan(db, deps, '2026-07-08') // d7
const second = followups(db).find((r) => r.duePeriod === 'd7')!
expect((await sendReminder(db, deps, second.id, 'u1')).ok).toBe(true)
expect(shareCount(db)).toBe(1) // reused, not duplicated
})
})
// ---------- STOP cleanup ----------
describe('quote_followup STOP cleanup', () => {
async function withOpenNudge(status?: 'accepted' | 'lost') {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]!
expect(rem.status).toBe('queued')
if (status !== undefined) markStatus(db, 'u1', q.id, status)
return { db, q, remId: rem.id }
}
it('markStatus accepted dismisses open nudges, each audited', async () => {
const { db, remId } = await withOpenNudge('accepted')
expect(getReminder(db, remId)!.status).toBe('dismissed')
const audit = db.prepare(
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder' AND entity_id=? AND action='update'`,
).get(remId) as { n: number }
expect(audit.n).toBe(1) // per-row setReminderStatus, not a silent bulk UPDATE
})
it('markStatus lost dismisses open nudges', async () => {
const { db, remId } = await withOpenNudge('lost')
expect(getReminder(db, remId)!.status).toBe('dismissed')
})
it('convertDocument dismisses the quotes open nudges in the same transaction', async () => {
const { db, q, remId } = await withOpenNudge()
convertDocument(db, 'u1', q.id, 'PROFORMA')
expect(getReminder(db, remId)!.status).toBe('dismissed')
})
it('leaves already-sent follow-ups untouched', async () => {
const { db, q, remId } = await withOpenNudge()
await sendReminder(db, deps, remId, 'u1')
markStatus(db, 'u1', q.id, 'accepted')
expect(getReminder(db, remId)!.status).toBe('sent') // history is history
})
})
// ---------- queue: labelling, owner filtering, pagination ----------
describe('listQueue — quote_followup labelling, owner scope, pagination', () => {
it('labels quote follow-ups and derives the owner from document.created_by', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
await runDailyScan(db, deps, '2026-07-04')
const page = listQueue(db)
expect(page.total).toBe(1)
expect(page.rows[0]!.label).toBe('Quote follow-up (d3)')
expect(page.rows[0]!.ownerId).toBe('staff-a')
expect(page.rows[0]!.clientName).toBe('Acme')
})
it('staff see only their own rows; managerial viewers see everything', async () => {
const { db, c, m } = world()
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'staff-b')
await runDailyScan(db, deps, '2026-07-04')
const a = listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' })
expect(a.total).toBe(1)
expect(a.rows[0]!.ownerId).toBe('staff-a')
const boss = listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' })
expect(boss.total).toBe(2)
// A staff request cannot widen its scope via ownerId.
const sneaky = listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a', ownerId: 'staff-b' })
expect(sneaky.total).toBe(1)
expect(sneaky.rows[0]!.ownerId).toBe('staff-a')
})
it('paginates with an honest total', async () => {
const { db, c, m } = world()
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
sentQuote(db, c2, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const p1 = listQueue(db, { page: 1, pageSize: 1 })
const p2 = listQueue(db, { page: 2, pageSize: 1 })
expect(p1.total).toBe(2); expect(p2.total).toBe(2)
expect(p1.rows).toHaveLength(1); expect(p2.rows).toHaveLength(1)
expect(p1.rows[0]!.id).not.toBe(p2.rows[0]!.id)
})
})

@ -26,7 +26,8 @@ describe('reminder repo', () => {
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(listQueue(db).rows).toHaveLength(1) // Phase 6: listQueue paginates ({ rows, total, page, pageSize })
expect(listQueue(db).total).toBe(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', () => {

Loading…
Cancel
Save