feat(hq): GET /reminders/:id/preview — exact email via shared reminderContext

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 1 week ago
parent 48731fdb2b
commit 52e8a03c81

@ -36,7 +36,8 @@ import {
dismissReminder, getReminder, listQueue, listReminders, setSetting,
type ReminderStatus,
} from './repos-reminders'
import { sendReminder, type SendReminderDeps } from './send-reminder'
import { sendReminder, reminderContext, type SendReminderDeps } from './send-reminder'
import { reminderEmail } from './reminder-templates'
import { dashboardView } from './repos-dashboard'
import {
costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput,
@ -507,6 +508,21 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
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) })
})
r.get('/reminders/:id/preview', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
const reminder = getReminder(db, id)
if (reminder === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
try {
if (reminder.ruleKind === 'follow_up' || reminder.ruleKind === 'email_bounced') {
throw new Error(`A ${reminder.ruleKind} reminder is an internal item, not a sendable email`)
}
const { ctx } = reminderContext(db, reminder, companySettings()['company.name'] ?? '')
const mail = reminderEmail(reminder.ruleKind, ctx)
res.json({ ok: true, subject: mail.subject, body: mail.bodyText })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/reminders/:id/send', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '')
if (getReminder(db, id) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }

@ -1,11 +1,11 @@
import type { DB } from './db'
import type { GmailDeps, SendResult } from './gmail'
import { sendReminderEmail } from './gmail'
import { getClient } from './repos-clients'
import { getClient, type Client } from './repos-clients'
import { getClientModule } from './repos-modules'
import { getDocument } from './repos-documents'
import { getDocument, type Doc } from './repos-documents'
import { getAmc } from './repos-amc'
import { getReminder, setReminderStatus } from './repos-reminders'
import { getReminder, setReminderStatus, type Reminder } from './repos-reminders'
import { reminderEmail, type ReminderContext } from './reminder-templates'
import { documentHtml } from './templates'
@ -19,6 +19,31 @@ export interface SendReminderDeps {
now?: () => string
}
/** The per-rule email context + the resolved client/doc, shared by the send path
* and GET /reminders/:id/preview so the previewed email equals the sent one. */
export interface ReminderRender { ctx: ReminderContext; doc: Doc | null; client: Client }
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')
const ctx: ReminderContext = { clientName: client.name, companyName }
let doc: Doc | null = null
if (reminder.docId !== null) {
doc = getDocument(db, reminder.docId)
if (doc === null) throw new Error('Reminder document not found')
ctx.docNo = doc.docNo ?? undefined
ctx.amountPaise = doc.payablePaise
ctx.period = reminder.duePeriod
} else if (reminder.ruleKind === 'renewal_due') {
const cm = getClientModule(db, reminder.subjectId)
if (cm !== null && cm.nextRenewal !== null) ctx.dueDate = cm.nextRenewal
} else if (reminder.ruleKind === 'amc_expiring') {
const amc = getAmc(db, reminder.subjectId)
if (amc !== null) { ctx.coverage = amc.coverage; ctx.dueDate = amc.periodTo }
}
return { ctx, doc, client }
}
export async function sendReminder(
db: DB, deps: SendReminderDeps, reminderId: string, userId: string,
): Promise<SendResult> {
@ -27,30 +52,16 @@ export async function sendReminder(
if (reminder.ruleKind === 'follow_up' || reminder.ruleKind === 'email_bounced') {
throw new Error(`A ${reminder.ruleKind} reminder is an internal item, not a sendable email`)
}
const client = getClient(db, reminder.clientId)
if (client === null) throw new Error('Client not found')
const company = deps.company()
const companyName = company['company.name'] ?? ''
const { ctx, doc, client } = reminderContext(db, reminder, companyName)
// Build the per-rule context.
const ctx: ReminderContext = { clientName: client.name, companyName }
let attachment: { filename: string; data: Buffer } | undefined
let documentId: string | undefined
if (reminder.docId !== null) {
const doc = getDocument(db, reminder.docId)
if (doc === null) throw new Error('Reminder document not found')
if (doc !== null) {
documentId = doc.id
ctx.docNo = doc.docNo ?? undefined
ctx.amountPaise = doc.payablePaise
ctx.period = reminder.duePeriod
const pdf = await deps.renderPdf(documentHtml(doc, client, company))
attachment = { filename: `${(doc.docNo ?? 'draft').replaceAll('/', '-')}.pdf`, data: pdf }
} else if (reminder.ruleKind === 'renewal_due') {
const cm = getClientModule(db, reminder.subjectId)
if (cm !== null && cm.nextRenewal !== null) ctx.dueDate = cm.nextRenewal
} else if (reminder.ruleKind === 'amc_expiring') {
const amc = getAmc(db, reminder.subjectId)
if (amc !== null) { ctx.coverage = amc.coverage; ctx.dueDate = amc.periodTo }
}
const to = client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email

@ -0,0 +1,51 @@
// apps/hq/test/reminder-preview.test.ts
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument } from '../src/repos-documents'
import { upsertReminder } from '../src/repos-reminders'
import { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.name='Tecnostac'
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
const overdue = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id })
const internal = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: c.id })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
return { server, base, inv, overdueId: overdue.id, internalId: internal.id }
}
describe('GET /reminders/:id/preview', () => {
const ctx = appWith()
afterAll(() => ctx.server.close())
const login = async () =>
(await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
const get = async (token: string | null, id: string) => {
const res = await fetch(`${ctx.base}/reminders/${id}/preview`, { headers: token ? { authorization: `Bearer ${token}` } : {} })
return { status: res.status, json: await res.json() as any }
}
it('requires auth', async () => { expect((await get(null, ctx.overdueId)).status).toBe(401) })
it('previews the overdue email — subject + body match what would be sent', async () => {
const token = await login()
const r = await get(token, ctx.overdueId)
expect(r.status).toBe(200)
expect(r.json.subject).toContain(ctx.inv.docNo) // 'Payment reminder — Invoice INV/26-27-0001 (Tecnostac)'
expect(r.json.subject).toContain('Tecnostac')
expect(r.json.body).toContain('Acme') // clientName
})
it('404s an unknown reminder and 400s an internal follow_up', async () => {
const token = await login()
expect((await get(token, 'nope')).status).toBe(404)
expect((await get(token, ctx.internalId)).status).toBe(400)
})
})
Loading…
Cancel
Save