feat(hq): reminder email send reusing gmail, plus manual-queue routes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent a5f6b6fdaf
commit 3204a9af97

@ -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<GmailDeps>): 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 {

@ -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<SendResult> {
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 }
}

@ -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<Buffer>
company: () => Record<string, string>
now?: () => string
}
export async function sendReminder(
db: DB, deps: SendReminderDeps, reminderId: string, userId: string,
): Promise<SendResult> {
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
}

@ -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)
})
})
Loading…
Cancel
Save