Workflow-App/Workflow/lib/letter-runner.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

546 lines
19 KiB
TypeScript

// 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<AssembleLetterResult> {
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<string>();
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<LetterContext> {
// 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/<collection>/<record>/`.
* We fetch them with the admin token.
*/
async function downloadSignerSignature(
signerId: string,
signatureField: string
): Promise<string | null> {
// 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,
};
}