547 lines
19 KiB
TypeScript
547 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,
|
||
|
|
};
|
||
|
|
}
|