Workflow-App/Workflow/lib/letter.ts

528 lines
18 KiB
TypeScript
Raw Normal View History

// 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<LetterType, string> = {
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<DocxBuild> {
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<LetterOutput> {
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<LetterDocxOutput> {
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<string[]> {
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<string | null> {
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<string | null> {
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<RunResult> {
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<string> {
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;
}