// lib/letter-runner.ts // SERVER-ONLY — orchestrates the full Phase 6 letter workflow for a given // resolution. Called by: // // 1. app/api/resolutions/sign/[token]/route.ts — when last signer signs // (this is the FINAL send: assemble the signed letter + email everyone) // // 2. app/api/resolutions/[id]/letter/route.ts — GET on demand (preview / // re-download) without sending emails // // What this module does: // 1. Fetch resolution + company + attendees + signers from PB // 2. Download each signed signer's signature PNG to a temp file // 3. Call generateLetter() to assemble + render the PDF // 4. Upload the PDF to PB under resolution.signed_letter // 5. Optionally send the PDF to every signer + company primary email // 6. Return a summary { ok, final_letter_file_id, emailed_to, errors } // // Failure mode: if ANY step fails, we still try to upload what we have and // surface the partial result. We never throw — callers need to record the // outcome on the resolution record so we can retry later. import "server-only"; import { promises as fs } from "node:fs"; import * as path from "node:path"; import { pbFetch, createRecordMultipart, updateRecord, } from "@/lib/pb"; import { generateLetter, saveSignatureToTempFile, type LetterContext, type LetterAttendee, type LetterSigner, type LetterCompany, } from "@/lib/letter"; import { sendFinalLetterEmail, type EmailResult } from "@/lib/email"; const RES_COL = "pbc_266170881"; const ATTENDEE_COL = "pbc_1608833669"; const SIGNERS_COL = "pbc_1655122391"; const COMPANIES_COL = "pbc_3748838150"; // ysterbaard_companies /** * Fold `director_role` to a single canonical key so casing / wording drift * between the wizard, older fixtures and the PB select options can never make a * real director silently vanish from the letter. * * "resigned" | "RESIGNED DIRECTOR" | "Resigned_Director" -> "resigned" * "appointed" | "NEW DIRECTOR" | "New Director" -> "appointed" * "existing" | "EXISTING DIRECTOR" -> "existing" * "external" -> "external" */ export function normaliseRole(role: string | null | undefined): string { const key = (role ?? "").toLowerCase().replace(/[^a-z]/g, ""); switch (key) { case "resigned": case "resigneddirector": case "resignation": return "resigned"; case "appointed": case "appointment": case "newdirector": case "newappointment": return "appointed"; case "existing": case "existingdirector": return "existing"; case "external": return "external"; default: return key; } } /** * PocketBase `date` fields come back as full ISO timestamps, e.g. * "2026-10-09 00:00:00.000Z". That string was being substituted straight into * the letter ("DATE EMAIL WAS SENT" → "2026-10-09 00:00:00.000Z") and into the * attachment filename, which is not a presentable date for a legal document. * Render it as "9 October 2026" instead. * * The date is parsed as UTC (PB stores midnight UTC), so we read the UTC parts * rather than letting the machine's local timezone shift it by a day. */ export function formatSaDate(value: string | null | undefined): string { if (!value) return ""; // PB sends "2026-10-09 00:00:00.000Z" — space-separated, already Z-suffixed. // Normalise the space to "T" and only add a Z if there is no zone marker, // otherwise "...000Z" becomes "...000ZZ" and Date() returns NaN. let iso = value.trim().replace(" ", "T"); if (!/(Z|[+-]\d{2}:?\d{2})$/.test(iso)) iso += "Z"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return String(value); // unparseable — pass through return d.toLocaleDateString("en-ZA", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC", }); } export interface AssembleLetterResult { ok: boolean; resolution_id: string; /** Filename of the final PDF (suggested). */ filename: string; /** Number of signatures embedded. */ signatures_embedded: number; /** Any unfilled placeholders detected (should be empty for a healthy template). */ unfilled_placeholders: string[]; /** PB record id for the uploaded final letter (empty if upload failed). */ final_letter_record_id: string | null; /** Per-recipient email results (only populated when send=true). */ emailed: Array<{ to: string; result: EmailResult }>; /** Non-fatal errors (each step's error message, if any). */ errors: string[]; } /** * End-to-end: assemble the signed letter for a resolution, upload to PB, * and optionally send it to every signer. * * @param resolutionId The PB id of the resolution to assemble * @param opts.send Set false to skip email send (useful for preview) */ export async function assembleAndDistributeLetter( resolutionId: string, opts: { send: boolean } ): Promise { const errors: string[] = []; // 1. Load resolution + company + attendees + signers const ctx = await loadLetterContext(resolutionId).catch((err) => { errors.push(`load context: ${err instanceof Error ? err.message : String(err)}`); return null; }); if (!ctx) { return blankResult(resolutionId, errors); } // 2. Generate letter (PDF) let pdf: Buffer; let filename: string; let signatures_embedded: number; let unfilled_placeholders: string[]; try { const out = await generateLetter(ctx); pdf = out.pdf; filename = out.filename; signatures_embedded = out.signatures_embedded; unfilled_placeholders = out.unfilled_placeholders; } catch (err) { errors.push(`generate letter: ${err instanceof Error ? err.message : String(err)}`); return { ok: false, resolution_id: resolutionId, filename: "", signatures_embedded: 0, unfilled_placeholders: [], final_letter_record_id: null, emailed: [], errors, }; } // 3. Upload to PB as a file field on the resolution. // // The field is `signed_letter`. This code used to append to `final_letter`, // which does not exist on `ysterbaard_resolutions` — PocketBase rejected the // unknown field and the failure was swallowed into `errors`, so the assembled // signed PDF was never actually stored. // // GUARD — never clobber a filed document with a blank regeneration. // // Most resolutions are signed on paper and filed by hand, not through the app. // If this runs with zero in-app signatures embedded (signatures_embedded === 0) // while a signed_letter is ALREADY stored, the freshly rendered PDF is a blank // template. Writing it over the operator's real signed document is silent, // irreversible data loss — the filed PDF is gone and the record now looks // "filed" while showing bare signature lines. // // So: only auto-write when the slot is empty, or when this assembly actually // contains signatures. The email step below still sends the assembled PDF, so // nothing is lost by skipping the write. let final_letter_record_id: string | null = null; try { const existing = await pbFetch<{ signed_letter?: string }>( `/api/collections/${RES_COL}/records/${encodeURIComponent(resolutionId)}`, { fields: "signed_letter" } ).catch(() => ({ signed_letter: "" })); const hasFiledCopy = typeof existing?.signed_letter === "string" && existing.signed_letter !== ""; if (hasFiledCopy && signatures_embedded === 0) { errors.push( `kept existing signed_letter: this run embedded 0 signatures, so the ` + `regenerated PDF is an unsigned template and would have replaced the ` + `filed signed document` ); } else { const form = new FormData(); const file = new File([pdf], filename, { type: "application/pdf" }); form.append("signed_letter", file, filename); // PB requires multipart PATCH to update a single record with files const url = `${process.env.PB_URL}/api/collections/${RES_COL}/records/${encodeURIComponent(resolutionId)}`; const token = process.env.PB_TOKEN; const res = await fetch(url, { method: "PATCH", headers: token ? { Authorization: token } : {}, body: form, cache: "no-store", }); if (!res.ok) { const text = await res.text(); throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`); } const updated = (await res.json()) as { signed_letter?: string }; final_letter_record_id = updated.signed_letter ?? null; } } catch (err) { errors.push(`upload signed letter: ${err instanceof Error ? err.message : String(err)}`); } // 4. Send emails (if requested) const emailed: Array<{ to: string; result: EmailResult }> = []; if (opts.send) { // Write PDF to temp file (email helper takes a path) const tmpPath = path.join("/tmp", `letter-${resolutionId}-${Date.now()}.pdf`); await fs.writeFile(tmpPath, pdf); try { // Collect recipients: every signer + the company primary contact const recipients = new Set(); for (const s of ctx.signers) { if (s.attendee.email) recipients.add(s.attendee.email); } // We don't have a company-primary-email field on the resolution itself, // but the company's enterprise contact lives in the company record. // For now, just send to signers — Phase 7 can extend to company primary. for (const to of recipients) { const signer = ctx.signers.find((s) => s.attendee.email === to); const signerName = signer ? `${signer.attendee.name} ${signer.attendee.surname}`.trim() : "Signatory"; const result = await sendFinalLetterEmail({ to, signer_name: signerName, resolution_title: ctx.company.name, company_name: ctx.company.name, pdf_path: tmpPath, pdf_filename: filename, }); emailed.push({ to, result }); if (!result.ok) { errors.push(`email to ${to}: ${result.error ?? `exit ${result.exit_code}`}`); } } } finally { await fs.unlink(tmpPath).catch(() => {}); } } return { ok: errors.length === 0, resolution_id: resolutionId, filename, signatures_embedded, unfilled_placeholders, final_letter_record_id, emailed, errors, }; } // ─── Context loader ────────────────────────────────────────────────────── export async function loadLetterContext( resolutionId: string ): Promise { // Fetch resolution with company expand const resList = await pbFetch<{ items: Array<{ id: string; status: string; meeting_date: string; company: string; expand?: { company?: { id: string; enterprise_name: string; enterprise_no?: string; // Physical address fields physical_address_line_1?: string; physical_address_line_2?: string; physical_town_city?: string; physical_province?: string; physical_postal_code?: string; company_email?: string; }; }; }>; }>(`/api/collections/${RES_COL}/records`, { perPage: 1, filter: `id="${resolutionId.replace(/"/g, "''")}"`, expand: "company", }); const res = resList.items[0]; if (!res) throw new Error(`Resolution ${resolutionId} not found`); const company = res.expand?.company; if (!company) throw new Error(`Resolution ${resolutionId} has no expanded company`); const attendees = await pbFetch<{ items: Array<{ id: string; name: string; surname: string; email: string | null; director_role: string; attended: boolean; must_sign: boolean; director?: string | null; effective_date?: string; expand?: { director?: { id: string; /** Full SA ID number. This is the real field on `ysterbaard_directors`. * There is no `id_number` field — reading that returned undefined and * silently dropped every ID from the letter. */ id_no?: string | null; id_no_masked?: string | null; /** Cell number. */ telephone?: string | null; }; }; }>; }>(`/api/collections/${ATTENDEE_COL}/records`, { perPage: 500, filter: `resolution="${resolutionId}"`, expand: "director", }); const signers = await pbFetch<{ items: Array<{ id: string; status: string; attendee: string; sign_token: string; email_to: string; sent_at: string | null; signed_at: string | null; signature?: string; expand?: { attendee?: { id: string; name: string; surname: string; email: string | null; director_role: string; director?: string | null; effective_date?: string; }; }; }>; }>(`/api/collections/${SIGNERS_COL}/records`, { perPage: 500, filter: `resolution="${resolutionId}"`, expand: "attendee", }); // Build attendees list with ID numbers const letterAttendees: LetterAttendee[] = attendees.items.map((a) => ({ id: a.id, name: a.name, surname: a.surname, email: a.email, director_role: a.director_role, attended: a.attended, must_sign: a.must_sign, director_id_number: a.expand?.director?.id_no || a.expand?.director?.id_no_masked || null, // Cell number for the signature block. It lives on the DIRECTOR record // (field: `telephone`), not on the attendee — the attendee only carries a // copy of the email. telephone: a.expand?.director?.telephone ?? null, effective_date: a.effective_date ? formatSaDate(a.effective_date) : null, })); // Build signers list, downloading signature PNGs to temp const letterSigners: LetterSigner[] = await Promise.all( signers.items.map(async (s) => { const att = s.expand?.attendee; const attId = att?.id ?? s.attendee; const attendee = letterAttendees.find((la) => la.id === attId) ?? { id: attId, name: att?.name ?? "", surname: att?.surname ?? "", email: att?.email ?? null, director_role: att?.director_role ?? "DIRECTOR", attended: true, must_sign: true, director_id_number: null, telephone: null, effective_date: null, }; let signature_path: string | null = null; if (s.signature && s.status === "signed") { signature_path = await downloadSignerSignature(s.id, s.signature); } return { id: s.id, attendee_id: attId, status: s.status as "pending" | "signed" | "expired", signature_path, signed_at: s.signed_at, attendee, }; }) ); // Resigning + newly-appointed labels. // // CRITICAL: the canonical values written by the app are LOWERCASE and short // — app/api/resolutions/create/route.ts writes "resigned" / "appointed" / // "existing" / "external". Older code (and scripts/smoke_phase6.py) compared // against "RESIGNED DIRECTOR" / "NEW DIRECTOR", which never match real data. // That made `resigners` empty, both labels fell back to the "None this // meeting" sentinel, and _neutralise_empty_sections() then DELETED the // resignation and appointment sections from the letter entirely. // // normaliseRole() therefore folds case and separators so every spelling the // DB or an old fixture might contain resolves to the same role. const resigners = letterAttendees.filter( (a) => normaliseRole(a.director_role) === "resigned" ); const newlyAppointed = letterAttendees.filter( (a) => normaliseRole(a.director_role) === "appointed" ); // Address formatting — use the structured address fields from // ysterbaard_companies (physical_address_line_1/2/_town_city/_province/_postal_code). const c = company; const addressLines = [ c.physical_address_line_1, c.physical_address_line_2, [c.physical_town_city, c.physical_province, c.physical_postal_code] .filter(Boolean) .join(", "), ].filter(Boolean) as string[]; const address = addressLines.join(", "); const address_city = c.physical_town_city ?? ""; const letterCompany: LetterCompany = { name: c.enterprise_name, enterprise_no: c.enterprise_no ?? "", address, address_city, }; return { letter_type: "director_change", resolution_id: resolutionId, meeting_date: formatSaDate(res.meeting_date), company: letterCompany, attendees: letterAttendees, signers: letterSigners, resigning_director_label: resigners.length > 0 ? resigners .map((a) => `${a.name} ${a.surname}${a.director_id_number ? ` (${a.director_id_number})` : ""}`.trim() ) .join(" and ") // When there are no resignations this meeting, set a sentinel // string that subst.py recognises. The python subst uses this // string to neutralise §2 / OR1 / OR2 paragraphs (replacing the // imperative "the resignation of [X]" prose with a one-line // "None this meeting" notice instead of leaving a literal // placeholder in the rendered PDF). : "None this meeting", newly_appointed_director_label: newlyAppointed.length > 0 ? newlyAppointed .map((a) => `${a.name} ${a.surname}${a.director_id_number ? ` (${a.director_id_number})` : ""}`.trim() ) .join(" and ") // Same sentinel — subst.py neutralises §3 / OR3 / OR4 paragraphs. : "None this meeting", }; } /** * Download a signer's signature PNG from PocketBase file storage to a temp * file. PB file URLs are like `/api/files///`. * We fetch them with the admin token. */ async function downloadSignerSignature( signerId: string, signatureField: string ): Promise { // signatureField is the filename (PB stores files by name) // The record id is needed for the URL — but PB returns just the filename // from the field accessor. We need the parent collection's id. const token = process.env.PB_TOKEN; const signersUrl = `${process.env.PB_URL}/api/collections/${SIGNERS_COL}/records/${signerId}`; try { const recRes = await fetch(signersUrl, { headers: { Authorization: token ?? "" }, cache: "no-store", }); if (!recRes.ok) return null; const rec = (await recRes.json()) as { collectionId: string; id: string }; const fileUrl = `${process.env.PB_URL}/api/files/${rec.collectionId}/${rec.id}/${signatureField}`; const fileRes = await fetch(fileUrl, { headers: { Authorization: token ?? "" }, cache: "no-store", }); if (!fileRes.ok) return null; const buf = Buffer.from(await fileRes.arrayBuffer()); return await saveSignatureToTempFile(signerId, buf); } catch { return null; } } function blankResult( resolutionId: string, errors: string[] ): AssembleLetterResult { return { ok: false, resolution_id: resolutionId, filename: "", signatures_embedded: 0, unfilled_placeholders: [], final_letter_record_id: null, emailed: [], errors, }; }