From 3204a9af97858684a1a4f75c92c936501e1d0e7b Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 15:21:49 +0530 Subject: [PATCH] feat(hq): reminder email send reusing gmail, plus manual-queue routes Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 40 +++++++++++++++++ apps/hq/src/gmail.ts | 62 +++++++++++++++++++++++++++ apps/hq/src/send-reminder.ts | 69 ++++++++++++++++++++++++++++++ apps/hq/test/send-reminder.test.ts | 59 +++++++++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 apps/hq/src/send-reminder.ts create mode 100644 apps/hq/test/send-reminder.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 4be5b96..0ced5be 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -31,6 +31,11 @@ import { createInteraction, createInteractionType, getInteraction, listInteractions, listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch, } from './repos-interactions' +import { + dismissReminder, getReminder, listQueue, listReminders, + type ReminderStatus, +} from './repos-reminders' +import { sendReminder, type SendReminderDeps } from './send-reminder' import { documentHtml } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' @@ -415,6 +420,41 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { } }) + const sendReminderDeps = (): SendReminderDeps => ({ + gmail: gmail(), renderPdf, company: companySettings, + }) + + // ---------- reminders (manual queue) ---------- + 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) }) + }) + 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 } + void (async () => { + try { + const out = await sendReminder(db, sendReminderDeps(), id, staffId(res)) + if (!out.ok) { + res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error }) + return + } + res.json({ ok: true, reminder: getReminder(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + })() + }) + r.post('/reminders/:id/dismiss', 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 } + try { + res.json({ ok: true, reminder: dismissReminder(db, staffId(res), id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + // ---------- payments ---------- r.post('/payments', requireAuth, (req, res) => { try { diff --git a/apps/hq/src/gmail.ts b/apps/hq/src/gmail.ts index 5b3485c..4a53ffc 100644 --- a/apps/hq/src/gmail.ts +++ b/apps/hq/src/gmail.ts @@ -156,3 +156,65 @@ export async function sendDocumentEmail( } return { ok: true } } + +export interface ReminderMailInput { + to: string; subject: string; bodyText: string + attachment?: { filename: string; data: Buffer } + documentId?: string +} + +/** + * Send a templated reminder — attachment optional, no document-status side effects. + * Mirrors sendDocumentEmail's account/token-death handling and logs every attempt, + * so the dashboard banner and email_log stay consistent across both send paths. + */ +export async function sendReminderEmail( + db: DB, deps: GmailDeps, args: ReminderMailInput, +): Promise { + const logFail = (error: string): SendResult => { + logEmail(db, { + ...(args.documentId !== undefined ? { documentId: args.documentId } : {}), + to: args.to, subject: args.subject, status: 'failed', error, + }) + return { ok: false, error } + } + const account = getAccount(db) + if (account === null) return logFail('gmail-not-connected') + if (account.status === 'dead') return logFail('gmail-token-dead') + + let accessToken: string + try { + const refreshToken = decrypt(account.refreshTokenEnc, deps.keyHex) + accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f) + } catch (err) { + if (err instanceof TokenDeadError) { markAccountDead(db); return logFail('gmail-token-dead') } + return logFail(err instanceof Error ? err.message : String(err)) + } + + const raw = buildMime({ + from: account.address, to: args.to, subject: args.subject, bodyText: args.bodyText, + ...(args.attachment !== undefined + ? { attachment: { filename: args.attachment.filename, contentType: 'application/pdf', data: args.attachment.data } } + : {}), + }) + let gmailMessageId: string | undefined + try { + const res = await deps.f(SEND_URL, { + method: 'POST', + headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' }, + body: JSON.stringify({ raw }), + }) + const json = await res.json().catch(() => ({})) as { id?: string } + if (!res.ok) return logFail(`Gmail send failed: HTTP ${res.status}`) + gmailMessageId = json.id + } catch (err) { + return logFail(err instanceof Error ? err.message : String(err)) + } + + logEmail(db, { + ...(args.documentId !== undefined ? { documentId: args.documentId } : {}), + to: args.to, subject: args.subject, status: 'sent', + ...(gmailMessageId !== undefined ? { gmailMessageId } : {}), + }) + return { ok: true } +} diff --git a/apps/hq/src/send-reminder.ts b/apps/hq/src/send-reminder.ts new file mode 100644 index 0000000..2f09360 --- /dev/null +++ b/apps/hq/src/send-reminder.ts @@ -0,0 +1,69 @@ +import type { DB } from './db' +import type { GmailDeps, SendResult } from './gmail' +import { sendReminderEmail } from './gmail' +import { getClient } from './repos-clients' +import { getClientModule } from './repos-modules' +import { getDocument } from './repos-documents' +import { getAmc } from './repos-amc' +import { getReminder, setReminderStatus } from './repos-reminders' +import { reminderEmail, type ReminderContext } from './reminder-templates' +import { documentHtml } from './templates' + +/** Turn a queued/failed reminder into an actual email — used by the manual-queue + * Send button and by the scheduler's auto path. Reuses gmail.sendReminderEmail. */ + +export interface SendReminderDeps { + gmail: GmailDeps + renderPdf: (html: string) => Promise + company: () => Record + now?: () => string +} + +export async function sendReminder( + db: DB, deps: SendReminderDeps, reminderId: string, userId: string, +): Promise { + const reminder = getReminder(db, reminderId) + if (reminder === null) throw new Error('Reminder not found') + 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'] ?? '' + + // 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') + 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 + if (to === undefined) throw new Error('No recipient: add a contact email to the client') + + const mail = reminderEmail(reminder.ruleKind, ctx) + const out = await sendReminderEmail(db, deps.gmail, { + to, subject: mail.subject, bodyText: mail.bodyText, + ...(attachment !== undefined ? { attachment } : {}), + ...(documentId !== undefined ? { documentId } : {}), + }) + const now = deps.now?.() ?? new Date().toISOString() + if (out.ok) setReminderStatus(db, userId, reminderId, 'sent', { sentAt: now, error: null }) + else setReminderStatus(db, userId, reminderId, 'failed', { error: out.error }) + return out +} diff --git a/apps/hq/test/send-reminder.test.ts b/apps/hq/test/send-reminder.test.ts new file mode 100644 index 0000000..eeb7702 --- /dev/null +++ b/apps/hq/test/send-reminder.test.ts @@ -0,0 +1,59 @@ +// apps/hq/test/send-reminder.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft, issueDocument } from '../src/repos-documents' +import { saveAccount } from '../src/repos-email' +import { encrypt } from '../src/crypto' +import { upsertReminder, getReminder } from '../src/repos-reminders' +import { sendReminder, type SendReminderDeps } from '../src/send-reminder' + +const KEY = '11'.repeat(32) +const fakePdf = async () => Buffer.from('%PDF-fake') +const company = () => ({ 'company.name': 'Tecnostac' }) + +function deps(f: typeof fetch): SendReminderDeps { + return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: fakePdf, company, now: () => '2026-07-10T09:00:00Z' } +} + +function invoiceSetup() { + 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: '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) + return { db, c, inv } +} + +describe('sendReminder', () => { + it('sends an overdue reminder with the invoice PDF and marks it sent', async () => { + const { db, c, inv } = invoiceSetup() + const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' }) + 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 out = await sendReminder(db, deps(okFetch), id, 'u1') + expect(out).toEqual({ ok: true }) + expect(getReminder(db, id)!.status).toBe('sent') + expect(getReminder(db, id)!.sentAt).toBe('2026-07-10T09:00:00Z') + const log = db.prepare(`SELECT status, document_id FROM email_log ORDER BY id DESC`).get() as { status: string; document_id: string } + expect(log).toMatchObject({ status: 'sent', document_id: inv.id }) + }) + it('leaves the reminder failed (not sent) on token death, and flips the account dead', async () => { + const { db, c, inv } = invoiceSetup() + const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' }) + const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch + const out = await sendReminder(db, deps(deadFetch), id, 'u1') + expect(out).toEqual({ ok: false, error: 'gmail-token-dead' }) + expect(getReminder(db, id)!.status).toBe('failed') + expect(db.prepare(`SELECT status FROM email_account`).get()).toMatchObject({ status: 'dead' }) + }) + it('refuses to email an internal follow_up reminder', async () => { + const { db, c } = invoiceSetup() + const { id } = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: c.id, now: '2026-07-10T00:00:00Z' }) + const noFetch = (async () => new Response('{}', { status: 200 })) as typeof fetch + await expect(sendReminder(db, deps(noFetch), id, 'u1')).rejects.toThrow(/sendable/i) + }) +})