diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 6bb5a12..19569c4 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -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): 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 } diff --git a/apps/hq/src/send-reminder.ts b/apps/hq/src/send-reminder.ts index 2f09360..cb5753d 100644 --- a/apps/hq/src/send-reminder.ts +++ b/apps/hq/src/send-reminder.ts @@ -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 { @@ -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 diff --git a/apps/hq/test/reminder-preview.test.ts b/apps/hq/test/reminder-preview.test.ts new file mode 100644 index 0000000..13ad555 --- /dev/null +++ b/apps/hq/test/reminder-preview.test.ts @@ -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) + }) +})