import type { DB } from './db' import type { GmailDeps, SendResult } from './gmail' import { sendReminderEmail } from './gmail' import { getClient, type Client } from './repos-clients' import { getClientModule } from './repos-modules' import { getDocument, type Doc } from './repos-documents' import { getAmc } from './repos-amc' import { getReminder, getSetting, resolveSchedule, setReminderStatus, type Reminder, } from './repos-reminders' import { mintShare, resolveLiveShare } from './repos-shares' 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 | Promise> 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 } /** Public URL for a share token: setting 'share.base_url' + /share/ (path-only when unset). */ async function shareUrlFor(db: DB, token: string): Promise { const base = await getSetting(db, 'share.base_url') return `${base !== null ? base.replace(/\/+$/, '') : ''}/share/${token}` } export async function reminderContext(db: DB, reminder: Reminder, companyName: string, today?: string): Promise { const client = await 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 = await 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 if (reminder.ruleKind === 'invoice_overdue') { // D18: anchored on the due date when the invoice carries one (stamped at issue), // else the issue date (legacy invoices) — renders "N day(s) past due"/"overdue". // `today` injectable like the quote branch below. // (Local day math: importing scheduler's helper here would create an import cycle.) const onDate = today ?? new Date().toISOString().slice(0, 10) const atUtc = (s: string): number => { const [y, m, d] = s.split('-').map(Number) return Date.UTC(y!, m! - 1, d!) } const anchor = doc.dueDate ?? doc.docDate if (doc.dueDate !== null) ctx.dueDate = doc.dueDate ctx.daysOverdue = Math.max(0, Math.round((atUtc(onDate) - atUtc(anchor)) / 86_400_000)) } if (reminder.ruleKind === 'quote_followup') { // `today` is injectable (deps.now via sendReminder) so the send path resolves the // SAME dated row as the scan that enqueued it — wall clock only as a fallback. const onDate = today ?? new Date().toISOString().slice(0, 10) const schedule = await resolveSchedule(db, 'quote_followup', onDate) // dated message text (rule 3) ctx.subjectTemplate = schedule.subject ?? undefined ctx.bodyTemplate = schedule.body ?? undefined // Quotes reach 'sent' without issuance (F10) — never render "quotation null". ctx.ref = doc.docNo ?? `dated ${doc.docDate}` // Resolve-only (F5): this builder is shared with GET /preview and must write // nothing. Minting happens exclusively in sendReminder below. const live = await resolveLiveShare(db, doc.id) ctx.shareUrl = live !== null ? await shareUrlFor(db, live.token) : '(a view link is generated when this reminder is sent)' } } else if (reminder.ruleKind === 'renewal_due') { const cm = await getClientModule(db, reminder.subjectId) if (cm !== null && cm.nextRenewal !== null) ctx.dueDate = cm.nextRenewal } else if (reminder.ruleKind === 'amc_expiring') { const amc = await 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 { const reminder = await 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 company = await deps.company() const companyName = company['company.name'] ?? '' const now = deps.now?.() ?? new Date().toISOString() // Resolve the recipient BEFORE any write: a send that can never succeed must not // leave a live public share behind as a side effect (rule 6). const recipient = await getClient(db, reminder.clientId) if (recipient === null) throw new Error('Client not found') const to = recipient.contacts.find((c) => c.email !== undefined && c.email !== '')?.email if (to === undefined) throw new Error('No recipient: add a contact email to the client') if (reminder.ruleKind === 'quote_followup' && reminder.docId !== null) { // The share link IS the point of this mail (spec §7): with no public base URL the // body would carry a dead relative path — refuse loudly instead of emailing it. if (await getSetting(db, 'share.base_url') === null) { throw new Error(`Setting 'share.base_url' is not configured — cannot email a usable share link`) } // The ONLY place a follow-up share is minted (rule 6 / F5): reuse a live link, // else mint with an expiry covering the escalation window — never never-expiring (F12). if (await resolveLiveShare(db, reminder.docId) === null) { await mintShare(db, userId, reminder.docId, { expiresDays: 60 }) } } const { ctx, doc, client } = await reminderContext(db, reminder, companyName, now.slice(0, 10)) let attachment: { filename: string; data: Buffer } | undefined let documentId: string | undefined if (doc !== null) { documentId = doc.id const pdf = await deps.renderPdf(documentHtml(doc, client, company)) attachment = { filename: `${(doc.docNo ?? 'draft').replaceAll('/', '-')}.pdf`, data: pdf } } 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 } : {}), }) if (out.ok) await setReminderStatus(db, userId, reminderId, 'sent', { sentAt: now, error: null }) else await setReminderStatus(db, userId, reminderId, 'failed', { error: out.error }) return out }