Workflow-App/Workflow/lib/letter.ts
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

527 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}