You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/send-reminder.ts

135 lines
6.9 KiB
TypeScript

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<Buffer>
company: () => Record<string, string> | Promise<Record<string, string>>
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/<token> (path-only when unset). */
async function shareUrlFor(db: DB, token: string): Promise<string> {
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<ReminderRender> {
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<SendResult> {
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
}