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.
527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
// 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;
|
||
}
|