// lib/letter.ts // SERVER-ONLY letter assembly for resolution PDF generation. // // Phase 6 — letter template integration. // // Pipeline: // 1. Load the corrected master template (currently: // director_change_resolution_master.docx) // 2. Download each signed signer's signature PNG from PB to a temp file // 3. Spawn `python3 scripts/subst.py` to: // - substitute every [PLACEHOLDER] with real values // - embed each signature PNG above its signature line // (we use python-docx via the standalone subst.py script — it preserves // run formatting cleanly, which is painful with the npm docx package // for in-place edits) // 4. Spawn `soffice --headless --convert-to pdf` to render the docx // 5. Return the PDF as a Buffer (callers upload it to PB / stream to user) import "server-only"; import { spawn } from "node:child_process"; import { promises as fs } from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import { randomBytes } from "node:crypto"; import { existsSync } from "node:fs"; // ─── Public types ──────────────────────────────────────────────────────── export type LetterType = "director_change"; export interface LetterAttendee { id: string; name: string; surname: string; email: string | null; director_role: string; attended: boolean; must_sign: boolean; /** SA ID number for the director — used in resolution text. */ director_id_number?: string | null; /** Cell number, from the director's `telephone` field. Shown under the * signature line when present. */ telephone?: string | null; /** Optional ISO date the appointment / resignation takes effect. */ effective_date?: string | null; } export interface LetterSigner { id: string; attendee_id: string; status: "pending" | "signed" | "expired"; /** * Absolute path on disk to the rendered signature PNG. * Caller must populate this from PB before invoking generateLetter(). * If null/undefined OR file doesn't exist, that slot is left blank. */ signature_path: string | null; signed_at: string | null; attendee: LetterAttendee; } export interface LetterCompany { name: string; enterprise_no: string; /** Multi-line address — already formatted. */ address: string; /** City portion only, for "Signed at COMPANY [ADDRESS_CITY]". */ address_city: string; } export interface LetterContext { letter_type: LetterType; resolution_id: string; /** ISO date — YYYY-MM-DD. */ meeting_date: string; company: LetterCompany; attendees: LetterAttendee[]; signers: LetterSigner[]; /** Resigning director's "Name Surname (ID)" — null if no resignations. */ resigning_director_label: string | null; /** Newly appointed director's "Name Surname (ID)" — null if none. */ newly_appointed_director_label: string | null; } export interface LetterOutput { pdf: Buffer; filename: string; signatures_embedded: number; unfilled_placeholders: string[]; } /** The editable Word twin of the letter, before DOCX→PDF conversion. */ export interface LetterDocxOutput { docx: Buffer; docxFilename: string; signatures_embedded: number; unfilled_placeholders: string[]; } // ─── Template registry ─────────────────────────────────────────────────── // // The master template used to live on a KIOXIA volume attached to the Mac // server (`/Volumes/KIOXIA/.hermes/...`), which does not exist on this PC — // letter generation failed with ENOENT. The master now lives in the repo at // assets/templates/. LETTER_TEMPLATE_DIR overrides both. const TEMPLATE_DIR = process.env.LETTER_TEMPLATE_DIR?.trim() || path.join(process.cwd(), "assets", "templates"); const TEMPLATES: Record = { director_change: "director_change_resolution_master.docx", }; function templatePathFor(type: LetterType): string { const fname = TEMPLATES[type]; if (!fname) { throw new Error(`No template registered for letter type "${type}"`); } const p = path.join(TEMPLATE_DIR, fname); if (!existsSync(p)) { throw new Error( `Letter template not found: ${p}. Set LETTER_TEMPLATE_DIR to the folder ` + `containing ${fname}.` ); } return p; } // ─── Public API ────────────────────────────────────────────────────────── /** Filename stem shared by the PDF and the DOCX, e.g. * `Resolution_ufc_Oct-09-2026`. One stem so the two files an operator * downloads are obviously the same document. */ function filenameStem(ctx: LetterContext): string { const slug = ctx.company.name .replace(/[^a-z0-9]+/gi, "-") .replace(/^-|-$/g, "") .toLowerCase() || "company"; // ctx.meeting_date is display-formatted ("9 October 2026"), so squash it to // a filename-safe stamp ("Oct-09-2026"). If it is not in that shape, fall // back to stripping anything that is not alphanumeric. const dateSlug = (() => { const year = ctx.meeting_date.match(/\b(\d{4})\b/)?.[1]; const day = ctx.meeting_date.match(/^\s*(\d{1,2})\b/)?.[1]; const month = ctx.meeting_date.match(/\b([A-Za-z]{3,})\b/)?.[1]; if (year && day && month) return `${month.slice(0, 3)}-${day.padStart(2, "0")}-${year}`; return ctx.meeting_date.replace(/[^A-Za-z0-9]+/g, "") || "undated"; })(); return `Resolution_${slug}_${dateSlug}`; } /** What buildDocx hands back: the substituted file plus the temp dir holding it. * The CALLER owns `workDir` and must remove it — the PDF path still needs the * file on disk for the DOCX→PDF conversion after this returns. */ interface DocxBuild { workDir: string; outDocx: string; filenameStem: string; signatures_embedded: number; } /** * Steps 1–3 of the pipeline: write context.json and run subst.py. * * Shared by generateLetter (which then converts to PDF) and * generateLetterDocx (which stops here). Extracting it is what lets the Word * download reuse the exact same substitution as the emailed PDF instead of * being a second rendering path that can drift. */ async function buildDocx(ctx: LetterContext): Promise { const templatePath = templatePathFor(ctx.letter_type); await fs.access(templatePath); const workDir = await fs.mkdtemp(path.join(os.tmpdir(), "ybletter-")); const outDocx = path.join(workDir, "letter.docx"); const ctxPath = path.join(workDir, "context.json"); await fs.mkdir(path.join(workDir, "pdf"), { recursive: true }); try { // 2. Write context.json — the python side reads the signature paths from // it, so they must be included. await fs.writeFile(ctxPath, JSON.stringify(ctx, null, 2), "utf8"); // 3. Run python-docx substitution // // Resolve from process.cwd() (the project root), NOT __dirname: inside a // Next.js server bundle __dirname points at .next/server/app/api/... so a // __dirname-relative walk resolved to a path that does not exist and python // failed with "can't open file". Fall back to __dirname for non-Next usage. const substScript = resolveSubstScript(); const substRes = await run( resolvePython(), [substScript, templatePath, outDocx, ctxPath, workDir], { timeoutMs: 60_000 } ); if (substRes.exitCode !== 0) { throw new Error( `letter substitution failed (exit ${substRes.exitCode}): ${substRes.stderr.slice(0, 800)}` ); } } catch (err) { // Nothing downstream can use a half-built temp dir. fs.rm(workDir, { recursive: true, force: true }).catch(() => {}); throw err; } return { workDir, outDocx, filenameStem: filenameStem(ctx), signatures_embedded: ctx.signers.filter( (s) => s.status === "signed" && s.signature_path !== null && s.signature_path !== undefined ).length, }; } /** * Generate the final signed letter PDF. */ export async function generateLetter( ctx: LetterContext ): Promise { const built = await buildDocx(ctx); const { workDir, outDocx } = built; const pdfOutDir = path.join(workDir, "pdf"); try { // 4. Convert docx → PDF (Microsoft Word first, LibreOffice as fallback) const pdfPath = await convertDocxToPdf(outDocx, pdfOutDir); if (!pdfPath) { throw new Error( "No DOCX→PDF converter available. Neither Microsoft Word (COM) nor " + "LibreOffice (soffice) could be used. Install LibreOffice, or set " + "SOFFICE_BIN to the full path of soffice.exe." ); } const pdfBuf = await fs.readFile(pdfPath); const unfilled_placeholders = await findUnfilledPlaceholders(outDocx); return { pdf: pdfBuf, filename: `${built.filenameStem}.pdf`, signatures_embedded: built.signatures_embedded, unfilled_placeholders, }; } finally { // Tidy up temp dir (best effort) fs.rm(workDir, { recursive: true, force: true }).catch(() => {}); } } /** * Generate the same letter as an editable .docx, stopping before conversion. * * This is the Word twin of generateLetter, not a second rendering path: both * call buildDocx, so the substitution, the signature embedding and the template * are literally the same document. Only the DOCX→PDF step is skipped, which is * why the download returns in about a second instead of waiting on Word. * * Section 5 has its own equivalent (`lib/shareholder-letter.ts`). It is not * shared: that module encodes a different legal document, and section 4's * output must stay exactly as it is. */ export async function generateLetterDocx( ctx: LetterContext ): Promise { const { workDir, outDocx, filenameStem: stem, signatures_embedded } = await buildDocx(ctx); try { const docx = await fs.readFile(outDocx); return { docx, docxFilename: `${stem}.docx`, signatures_embedded, unfilled_placeholders: await findUnfilledPlaceholders(outDocx), }; } finally { fs.rm(workDir, { recursive: true, force: true }).catch(() => {}); } } /** * Scan the rendered docx for any remaining [PLACEHOLDER] tokens (excluding * the legal-template boilerplate ones we deliberately preserve). */ async function findUnfilledPlaceholders(docxPath: string): Promise { const script = ` import sys from docx import Document import re doc = Document(sys.argv[1]) pat = re.compile(r"\\[[^\\[\\]]+\\]") seen = set() for p in doc.paragraphs: for m in pat.findall(p.text): seen.add(m) for t in doc.tables: for row in t.rows: for c in row.cells: for p in c.paragraphs: for m in pat.findall(p.text): seen.add(m) KEEP = {"[resignation and/or appointment]"} for k in KEEP: seen.discard(k) for s in sorted(seen): print(s) `; const scriptPath = path.join(os.tmpdir(), `ybletter-grep-${randomBytes(4).toString("hex")}.py`); await fs.writeFile(scriptPath, script, "utf8"); try { const r = await run(resolvePython(), [scriptPath, docxPath], { timeoutMs: 30_000 }); return r.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean); } finally { fs.unlink(scriptPath).catch(() => {}); } } // ─── Helpers ───────────────────────────────────────────────────────────── /** Convert a .docx to .pdf, returning the PDF path. * * Conversion order: * 1. Microsoft Word via COM — installed on this PC and renders DOCX with * full fidelity, preserving the legal master template's formatting. * 2. LibreOffice `soffice` — the path the Mac server used. * 3. null — the caller reports it and still sends the email. */ async function convertDocxToPdf(docxPath: string, outDir: string): Promise { const pdfPath = path.join( outDir, path.basename(docxPath).replace(/\.docx$/i, ".pdf") ); // 1. Microsoft Word (COM via cscript/VBScript) const viaWord = await convertViaWord(docxPath, pdfPath); if (viaWord) return viaWord; // 2. LibreOffice const sofficeBin = resolveSoffice(); if (!sofficeBin) return null; return pdfPath; } /** Word COM automation via a generated VBScript. * * PowerShell's COM interop mangles Word's positional arguments * ("Cannot convert the True value of type bool to type Object"), and * cscript.exe is present on every Windows box. VBScript's late binding * drives Word cleanly, and Word renders DOCX with full fidelity — which * matters because this is a legal template. * * Returns the PDF path on success, null on any failure. */ async function convertViaWord(docxPath: string, pdfPath: string): Promise { const vbsPath = path.join( os.tmpdir(), `ybletter-word-${randomBytes(4).toString("hex")}.vbs` ); // Quote defensively — paths may contain spaces or ampersands. const q = (s: string) => `"${s.replace(/"/g, '""')}"`; const script = [ 'Set word = CreateObject("Word.Application")', "word.Visible = False", "word.DisplayAlerts = 0", `Set doc = word.Documents.Open(${q(docxPath)})`, `doc.ExportAsFixedFormat ${q(pdfPath)}, 17`, "doc.Close 0", "word.Quit", 'WScript.Echo "OK"', ].join("\r\n"); try { await fs.writeFile(vbsPath, script, "utf8"); const res = await run("cscript.exe", ["//NoLogo", "//B", vbsPath], { timeoutMs: 120_000, }); if (res.exitCode !== 0) { console.error( "[letter] Word conversion failed:", (res.stderr || res.stdout).slice(0, 300) ); return null; } return existsSync(pdfPath) ? pdfPath : null; } catch (e) { console.error( "[letter] Word conversion threw:", e instanceof Error ? e.message.slice(0, 300) : e ); return null; } finally { fs.unlink(vbsPath).catch(() => {}); } } /** Locate the LibreOffice CLI. * * `soffice` is on PATH under Homebrew (macOS) and most Linux images, but NOT * by default on Windows — where it lives in a fixed install directory. Check * the well-known Windows paths so a stock install just works, with * SOFFICE_BIN as an explicit override. */ function resolveSoffice(): string | null { const override = process.env.SOFFICE_BIN?.trim(); if (override && existsSync(override)) return override; const candidates = [ process.env.PROGRAMFILES && path.join(process.env.PROGRAMFILES, "LibreOffice", "program", "soffice.exe"), process.env["PROGRAMFILES(X86)"] && path.join(process.env["PROGRAMFILES(X86)"], "LibreOffice", "program", "soffice.exe"), process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Programs", "LibreOffice", "program", "soffice.exe"), "/Applications/LibreOffice.app/Contents/MacOS/soffice", "/usr/bin/soffice", "/usr/local/bin/soffice", ].filter((p): p is string => typeof p === "string" && p.length > 0); for (const c of candidates) { if (existsSync(c)) return c; } // Last resort: maybe it IS on PATH. return existsSync(path.join(os.homedir(), ".local", "bin", "soffice")) ? path.join(os.homedir(), ".local", "bin", "soffice") : null; } /** Locate a Python interpreter that has `python-docx` available. * * The pipeline used to spawn the literal "python3", which does not exist on * Windows — it resolved to the Microsoft Store stub and failed with exit * 9009. Prefer the project venv (which is where python-docx is installed), * then an explicit LETTER_PYTHON override, then a system interpreter. */ function resolvePython(): string { const venv = path.join( process.cwd(), "scripts", ".venv", process.platform === "win32" ? "Scripts" : "bin", process.platform === "win32" ? "python.exe" : "python" ); if (existsSync(venv)) return venv; const override = process.env.LETTER_PYTHON?.trim(); if (override && existsSync(override)) return override; // Nothing verified — fall back to the platform default and let the error // message from the spawn surface the real problem. return process.platform === "win32" ? "python" : "python3"; } /** Locate scripts/subst.py. * * `__dirname` is unreliable here: Next.js compiles this module into * .next/server/app/api/resolutions/[id]/, so walking up from it lands on a * path that does not exist. process.cwd() is the project root when running * under `next dev` / `next start`, which is correct. */ function resolveSubstScript(): string { const override = process.env.SUBST_SCRIPT?.trim(); if (override && existsSync(override)) return override; const fromCwd = path.join(process.cwd(), "scripts", "subst.py"); if (existsSync(fromCwd)) return fromCwd; const fromDir = path.resolve(__dirname, "..", "..", "scripts", "subst.py"); if (existsSync(fromDir)) return fromDir; throw new Error( `Could not find scripts/subst.py (looked in ${fromCwd} and ${fromDir}). ` + "Set SUBST_SCRIPT to its full path." ); } interface RunResult { exitCode: number; stdout: string; stderr: string; } function run( cmd: string, args: string[], opts: { timeoutMs: number } ): Promise { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.on("data", (b) => (stdout += b.toString())); child.stderr.on("data", (b) => (stderr += b.toString())); const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(`${cmd} timed out after ${opts.timeoutMs}ms`)); }, opts.timeoutMs); child.on("error", (err) => { clearTimeout(timer); reject(err); }); child.on("close", (code) => { clearTimeout(timer); resolve({ exitCode: code ?? -1, stdout, stderr }); }); }); } /** * Save a signer's signature PNG bytes (from PB file URL) to a temp file. * Returns absolute path; caller is responsible for cleanup if needed * (typically we let the OS clean up /tmp eventually). */ export async function saveSignatureToTempFile( signerId: string, pngBytes: Buffer ): Promise { const dir = path.join(os.tmpdir(), "ybsigs"); await fs.mkdir(dir, { recursive: true }); const fname = `${signerId}-${randomBytes(4).toString("hex")}.png`; const fullPath = path.join(dir, fname); await fs.writeFile(fullPath, pngBytes); return fullPath; }