feat(hq): reminder email send reusing gmail, plus manual-queue routes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
a5f6b6fdaf
commit
3204a9af97
@ -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…
Reference in New Issue