diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 7e251ba..43577fb 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -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 } } diff --git a/apps/hq-web/src/pages/Dashboard.tsx b/apps/hq-web/src/pages/Dashboard.tsx index 6c5684d..91142c3 100644 --- a/apps/hq-web/src/pages/Dashboard.tsx +++ b/apps/hq-web/src/pages/Dashboard.tsx @@ -10,8 +10,9 @@ const inr = (p: number) => formatINR(p) const RULE_LABEL: Record = { 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: {rem.status}, diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 15b7c7e..92b3b2c 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -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 ---------- diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index e0bf4b9..1841788 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -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'], + ) +} diff --git a/apps/hq/src/reminder-templates.ts b/apps/hq/src/reminder-templates.ts index a902777..80cf6b5 100644 --- a/apps/hq/src/reminder-templates.ts +++ b/apps/hq/src/reminder-templates.ts @@ -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 diff --git a/apps/hq/src/repos-dashboard.ts b/apps/hq/src/repos-dashboard.ts index 6e5f870..342c0a9 100644 --- a/apps/hq/src/repos-dashboard.ts +++ b/apps/hq/src/repos-dashboard.ts @@ -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 }, } } diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index 777f44a..92ead15 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -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 })() } diff --git a/apps/hq/src/repos-reminders.ts b/apps/hq/src/repos-reminders.ts index 18eec35..613e683 100644 --- a/apps/hq/src/repos-reminders.ts +++ b/apps/hq/src/repos-reminders.ts @@ -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 = { + 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 }, diff --git a/apps/hq/src/repos-shares.ts b/apps/hq/src/repos-shares.ts index 84032c1..b257905 100644 --- a/apps/hq/src/repos-shares.ts +++ b/apps/hq/src/repos-shares.ts @@ -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 diff --git a/apps/hq/src/scheduler.ts b/apps/hq/src/scheduler.ts index 5cac507..12a91c5 100644 --- a/apps/hq/src/scheduler.ts +++ b/apps/hq/src/scheduler.ts @@ -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) { diff --git a/apps/hq/src/send-reminder.ts b/apps/hq/src/send-reminder.ts index cb5753d..096cf5c 100644 --- a/apps/hq/src/send-reminder.ts +++ b/apps/hq/src/send-reminder.ts @@ -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/ (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 diff --git a/apps/hq/test/quote-followup.test.ts b/apps/hq/test/quote-followup.test.ts new file mode 100644 index 0000000..852a01e --- /dev/null +++ b/apps/hq/test/quote-followup.test.ts @@ -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 quote’s 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) + }) +}) diff --git a/apps/hq/test/reminders.test.ts b/apps/hq/test/reminders.test.ts index 1aba575..f18a7d3 100644 --- a/apps/hq/test/reminders.test.ts +++ b/apps/hq/test/reminders.test.ts @@ -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', () => {