Workflow-App/Workflow/app/api/resignations/resend/route.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

502 lines
23 KiB
TypeScript

// app/api/resignations/resend/route.ts
// POST /api/resignations/resend
//
// Re-send an existing resignation's email.
// mail=initial → re-send the initial letter
// mail=signed → re-send the signed distro (needs stored signature)
import { NextRequest, NextResponse } from "next/server";
import { fetchRecord, updateRecordMultipart } from "@/lib/pb";
import type { YsterbaardDirectorResignation } from "@/lib/types";
import { spawn } from "child_process";
import { HIMALAYA_BIN, himalayaSendArgs } from "@/lib/himalaya";
const PDFD_URL = process.env.PDFD_URL || "http://127.0.0.1:3070";
const PB_URL = "https://pb.agent.apphub.co.za";
const PB_TOKEN = process.env.PB_TOKEN || "";
interface ResigExpand {
director?: Record<string, unknown>;
company?: Record<string, unknown>;
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { id, mail } = body as { id?: string; mail?: "initial" | "signed" };
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
if (mail !== "initial" && mail !== "signed") {
return NextResponse.json({ error: "mail must be 'initial' or 'signed'" }, { status: 400 });
}
const appt = await fetchRecord<YsterbaardDirectorResignation>(
"ysterbaard_director_resignations",
id,
"director,company"
);
const director = (appt.expand as ResigExpand | undefined)?.director ?? {};
const company = (appt.expand as ResigExpand | undefined)?.company ?? {};
const directorName = [director.first_names, director.surname].filter(Boolean).join(" ") || "Director";
const companyName = String(company.enterprise_name ?? "the Company");
const signUrl = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:6001"}/resign/${appt.sign_token}`;
const to = appt.sent_to || "sj@dyfins.com";
const dirAddr = pickAddress(
{
line_1: director.residential_address_line_1 as string | null | undefined,
line_2: director.residential_address_line_2 as string | null | undefined,
town_city: director.residential_town_city as string | null | undefined,
province: director.residential_province as string | null | undefined,
postal_code: director.residential_postal_code as string | null | undefined,
},
director.residential_address as string | undefined
);
const coAddr = pickAddress(
{
line_1: company.physical_address_line_1 as string | null | undefined,
line_2: company.physical_address_line_2 as string | null | undefined,
town_city: company.physical_town_city as string | null | undefined,
province: company.physical_province as string | null | undefined,
postal_code: company.physical_postal_code as string | null | undefined,
},
company.physical_address as string | undefined
);
let eml: string;
let pdfBytes: Buffer | null = null;
let pdfFilename = "";
if (mail === "initial") {
const today = new Date().toISOString().split("T")[0];
const html = letterHtml({
directorName,
directorIdNo: String(director.id_no ?? ""),
directorEmail: String(director.email ?? ""),
directorCellPhone: String(director.telephone ?? ""),
residentialAddressLine1: dirAddr.line_1,
residentialAddressLine2: dirAddr.line_2,
residentialTownCity: dirAddr.town_city,
residentialProvince: dirAddr.province,
residentialPostalCode: dirAddr.postal_code,
companyName,
physicalAddressLine1: coAddr.line_1,
physicalAddressLine2: coAddr.line_2,
physicalTownCity: coAddr.town_city,
physicalProvince: coAddr.province,
physicalPostalCode: coAddr.postal_code,
signUrl,
letterDate: today,
});
pdfFilename = `resignation-letter-${companyName.replace(/[^A-Za-z0-9]/g, "_")}.pdf`;
pdfBytes = await renderPdf(html, `resignation-letter-${companyName.replace(/[^A-Za-z0-9]/g, "_")}`);
eml = buildInitialEml({ to, directorName, companyName, signUrl, pdfBytes, pdfFilename });
} else {
// signed
const signatureBase64 = await fetchSignatureBase64(appt);
if (!signatureBase64) {
return NextResponse.json({ error: "No signature stored — cannot resend signed copy." }, { status: 400 });
}
const date = new Date().toISOString().split("T")[0];
const directorForRender = {
...director,
residential_address_line_1: dirAddr.line_1,
residential_address_line_2: dirAddr.line_2,
residential_town_city: dirAddr.town_city,
residential_province: dirAddr.province,
residential_postal_code: dirAddr.postal_code,
};
const companyForRender = {
...company,
physical_address_line_1: coAddr.line_1,
physical_address_line_2: coAddr.line_2,
physical_town_city: coAddr.town_city,
physical_province: coAddr.province,
physical_postal_code: coAddr.postal_code,
};
const html = signedLetterHtml(directorForRender, companyForRender, date, signatureBase64, signUrl);
pdfFilename = `signed-resignation-${companyName.replace(/[^A-Za-z0-9]/g, "_")}.pdf`;
pdfBytes = await renderPdf(html, `signed-resignation-${companyName.replace(/[^A-Za-z0-9]/g, "_")}`);
eml = buildSignedEml({ to, directorName, companyName, date, pdfBytes, pdfFilename });
}
// Persist to PB
if (pdfBytes) {
try {
const pdfFile = new File([pdfBytes], pdfFilename, { type: "application/pdf" });
await updateRecordMultipart(
"ysterbaard_director_resignations",
appt.id,
{},
[{ field: "letter", file: pdfFile }]
);
} catch (saveErr) {
console.error("Resend: failed to persist PDF to PB:", saveErr);
}
}
await sendViaHimalaya(eml);
return NextResponse.json({ ok: true, mail });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error("Resend error:", msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
function pickAddress(
structured: { line_1?: string | null; line_2?: string | null; town_city?: string | null; province?: string | null; postal_code?: string | null },
flat?: string | null
): { line_1: string; line_2: string; town_city: string; province: string; postal_code: string } {
const s = (v: string | null | undefined) => (v ?? "").trim();
const line1 = s(structured.line_1);
const town = s(structured.town_city);
const zip = s(structured.postal_code);
const structuredLooksReal =
(line1 && !/FIXED|TEST|PLACEHOLDER|XXX/i.test(line1)) ||
(town && !/^(JOHANNESBURG|PRETORIA|CAPE TOWN|TEST)$/i.test(town)) ||
(zip && zip !== "2000" && zip !== "0000");
if (structuredLooksReal && (line1 || town || zip)) {
return { line_1: line1, line_2: s(structured.line_2), town_city: town, province: s(structured.province), postal_code: zip };
}
if (!flat) return { line_1: "", line_2: "", town_city: "", province: "", postal_code: "" };
const parts = flat.split(",").map((p) => p.trim()).filter(Boolean);
if (parts.length >= 5) {
return { line_1: parts[0], line_2: "", town_city: [parts[1], parts[2]].filter(Boolean).join(", "), province: parts[3], postal_code: parts[4] };
}
if (parts.length === 4) return { line_1: parts[0], line_2: "", town_city: parts[1], province: parts[2], postal_code: parts[3] };
if (parts.length === 3) return { line_1: parts[0], line_2: "", town_city: parts[1], province: "", postal_code: parts[2] };
if (parts.length === 2) return { line_1: parts[0], line_2: "", town_city: parts[1], province: "", postal_code: "" };
return { line_1: parts[0] || "", line_2: "", town_city: "", province: "", postal_code: "" };
}
/** Render the PDF, or return null if the local renderer is down.
*
* The pdfd service on PDFD_URL is often not running. Previously a failed
* render threw, which aborted the whole resend BEFORE himalaya was ever
* called — so a dead PDF service silently blocked email delivery and the
* user saw a bare 500. A missing attachment is recoverable; a lost letter
* is not, so we degrade to an HTML-only email and say so. */
async function renderPdf(html: string, filename: string): Promise<Buffer | null> {
try {
const res = await fetch(`${PDFD_URL}/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html, filename }),
signal: AbortSignal.timeout(20000),
});
if (!res.ok) {
console.error(`[resend] pdfd returned ${res.status} — sending without attachment`);
return null;
}
const data = (await res.json()) as { pdf: string };
return Buffer.from(data.pdf, "base64");
} catch (e) {
console.error("[resend] PDF render failed — sending without attachment:", e instanceof Error ? e.message : e);
return null;
}
}
async function fetchSignatureBase64(appt: YsterbaardDirectorResignation): Promise<string | null> {
if (!appt.signature) return null;
const url = `${PB_URL}/api/files/${appt.collectionId}/${appt.id}/${appt.signature}`;
const res = await fetch(url, { headers: PB_TOKEN ? { Authorization: PB_TOKEN } : {} });
if (!res.ok) return null;
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString('base64');
}
function buildInitialEml(opts: {
to: string; directorName: string; companyName: string; signUrl: string;
pdfBytes: Buffer | null; pdfFilename: string;
}): string {
const boundary = "----YSTERBAARD_" + Date.now();
let eml = `To: ${opts.to}\r\nFrom: SJ@Dyfins.com\r\nSubject: Letter of Resignation — ${opts.companyName}\r\nMIME-Version: 1.0\r\n`;
eml += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
eml += `--${boundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n`;
eml += initialEmailBodyHtml(opts.directorName, opts.companyName, opts.signUrl);
// The PDF service is optional — fall back to a text/plain-only message so
// the letter still reaches the director when the renderer is down.
if (opts.pdfBytes && opts.pdfFilename) {
eml += `\r\n--${boundary}\r\nContent-Type: application/pdf; name="${opts.pdfFilename}"\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename="${opts.pdfFilename}"\r\n\r\n`;
eml += opts.pdfBytes.toString("base64").replace(/(.{76})/g, "$1\r\n");
}
eml += `\r\n--${boundary}--\r\n`;
return eml;
}
function initialEmailBodyHtml(directorName: string, companyName: string, signUrl: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif;font-size:14px;color:#222;max-width:600px;margin:40px auto;padding:0 20px;">
<div style="background:#f8f9fa;border:1px solid #ddd;border-radius:8px;padding:24px;">
<p>Dear ${directorName},</p>
<p>Please find attached your <strong>Letter of Resignation</strong> for <strong>${companyName}</strong>.</p>
<div style="background:#fff;border:1px solid #eee;border-radius:6px;padding:20px;margin:20px 0;">
<p><strong>To sign electronically, click the button below and draw your signature:</strong></p>
<p style="text-align:center;margin:24px 0;">
<a href="${signUrl}" style="display:inline-block;background:#0066cc;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-size:14px;font-weight:bold;">Sign Resignation</a>
</p>
<p>Or copy this link:<br/><span style="color:#0066cc;font-size:12px;word-break:break-all;">${signUrl}</span></p>
</div>
<p>Yours sincerely,<br/><strong>${directorName}</strong></p>
</div></body></html>`;
}
function buildSignedEml(opts: {
to: string; directorName: string; companyName: string; date: string;
pdfBytes: Buffer | null; pdfFilename: string;
}): string {
const boundary = "----YSTERBAARD_" + Date.now();
let eml = `To: ${opts.to}\r\nFrom: SJ@Dyfins.com\r\nSubject: ${opts.directorName} signed resignation from ${opts.companyName}\r\nMIME-Version: 1.0\r\n`;
eml += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
eml += `--${boundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n`;
eml += signedEmailBodyHtml(opts.directorName, opts.companyName, opts.date);
if (opts.pdfBytes && opts.pdfFilename) {
eml += `\r\n--${boundary}\r\nContent-Type: application/pdf; name="${opts.pdfFilename}"\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename="${opts.pdfFilename}"\r\n\r\n`;
eml += opts.pdfBytes.toString("base64").replace(/(.{76})/g, "$1\r\n");
}
eml += `\r\n--${boundary}--\r\n`;
return eml;
}
function signedEmailBodyHtml(directorName: string, companyName: string, date: string): string {
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif;font-size:14px;color:#222;max-width:600px;margin:40px auto;padding:0 20px;">
<div style="background:#f8f9fa;border:1px solid #ddd;border-radius:8px;padding:24px;">
<h2 style="text-align:center;color:#0a0a0a;font-size:18px;margin:0 0 16px;">Letter of Resignation — Signed</h2>
<p>This is to confirm that <strong>${directorName}</strong> has signed the Letter of Resignation from <strong>${companyName}</strong>.</p>
<p>Date: ${date}</p>
<p style="color:#888;font-style:italic;">[Signature on file — see attached signed letter]</p>
<p>The full signed letter is attached as a PDF.</p>
</div></body></html>`;
}
async function sendViaHimalaya(eml: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(
HIMALAYA_BIN,
himalayaSendArgs("dyfins"),
{ stdio: ["pipe", "pipe", "pipe"] }
);
let err = "";
child.stderr.on("data", (d) => { err += d.toString(); });
child.on("error", (e) => reject(e));
child.on("close", (code) => {
if (code !== 0) reject(new Error(`himalaya exited ${code}: ${err}`));
else resolve();
});
child.stdin.write(eml);
child.stdin.end();
});
}
function letterHtml(opts: {
directorName: string;
directorIdNo: string;
directorEmail: string;
directorCellPhone: string;
residentialAddressLine1: string;
residentialAddressLine2: string;
residentialTownCity: string;
residentialProvince: string;
residentialPostalCode: string;
companyName: string;
physicalAddressLine1: string;
physicalAddressLine2: string;
physicalTownCity: string;
physicalProvince: string;
physicalPostalCode: string;
signUrl: string;
letterDate: string;
}): string {
const esc = (s: string) => String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
const dir = {
name: esc(opts.directorName), id: esc(opts.directorIdNo),
email: esc(opts.directorEmail), cell: esc(opts.directorCellPhone),
addr1: esc(opts.residentialAddressLine1), addr2: esc(opts.residentialAddressLine2),
town: esc(opts.residentialTownCity), province: esc(opts.residentialProvince),
zip: esc(opts.residentialPostalCode),
};
const co = {
name: esc(opts.companyName),
p1: esc(opts.physicalAddressLine1), p2: esc(opts.physicalAddressLine2),
pt: esc(opts.physicalTownCity), pp: esc(opts.physicalProvince), pz: esc(opts.physicalPostalCode),
};
const d = new Date(opts.letterDate);
const dayOfMonth = d.getUTCDate();
const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const monthName = monthNames[d.getUTCMonth()];
const year = d.getUTCFullYear();
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Letter of Resignation</title>
<style>
@page { size: A4; margin: 25mm; }
body { font-family: Arial, sans-serif; font-size: 11pt; color: #000; line-height: 1.2; margin: 0; }
.top-block { margin: 0; padding: 0; }
.addressee-block { margin: 0; padding: 0; line-height: 1.4; text-align: left; }
.hdr { font-weight: bold; margin: 14pt 0 4pt 0; }
p { margin: 6pt 0; text-align: justify; }
.sign-line { border-top: 1px solid #000; padding-top: 4pt; width: 60%; margin-top: 4pt; }
.sign-link-box { margin: 12pt 0; padding: 8pt; background: #f4f4f4; border: 1px solid #ccc; font-family: monospace; font-size: 9pt; text-align: left; word-break: break-all; }
.footer { margin-top: 24pt; font-size: 9pt; color: #555; text-align: center; font-style: italic; }
.signature-block { text-align: left; margin-top: 28pt; }
.signature-name { margin-top: 4pt; }
table { border-collapse: collapse; }
</style></head>
<body>
<table class="top-block" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align:left;width:35%;vertical-align:top;">&nbsp;</td>
<td style="text-align:right;width:65%;vertical-align:top;font-weight:bold;">${dir.name}<br/>
<span style="font-weight:normal;">${dir.addr1}${dir.addr2 ? `<br/>${dir.addr2}` : ``}<br/>
${[dir.town, dir.zip].filter(Boolean).join(", ")}${dir.province ? `<br/>${dir.province}` : ``}<br/>
${dir.email}<br/>
${dir.cell}<br/>
&nbsp;<br/>
<span style="font-weight:normal;">${opts.letterDate}</span></span>
</td>
</tr>
</table>
<div class="addressee-block"><strong>The Board</strong><br/>
${co.name}<br/>
${co.p1 ? `${co.p1}<br/>` : ``}${co.p2 ? `${co.p2}<br/>` : ``}
${co.pt ? `${co.pt}<br/>` : ``}
${co.pp ? `${co.pp}<br/>` : ``}
${co.pz ? `${co.pz}<br/>` : ``}
</div>
<div class="hdr">Dear Members of The Board</div>
<p>Please accept this letter as formal notification that I am resigning from my position as Director of the Company, effective immediately, in accordance with the provisions of the Companies Act 71 of 2008 (as amended).</p>
<p>I acknowledge that I have no claim for compensation for loss of office or otherwise against the Company, its officers, or its employees. I further acknowledge that, from this date forward, I am no longer bound by the duties, responsibilities, and liabilities of this office as defined by the Act, common law, and any other applicable legislation.</p>
<p>Please confirm receipt of this letter and the effective date of my resignation.</p>
<p>Signed at ${dir.town || "_____________"} on this ${dayOfMonth} day of ${monthName} ${year}.</p>
<div class="signature-block">
<div class="sign-line">&nbsp;</div>
<p class="signature-name">
<strong>${dir.name}</strong><br/>
ID ${dir.id}
</p>
</div>
<div class="sign-link-box"><strong>To sign electronically, visit:</strong><br/>${opts.signUrl}</div>
<p>Alternatively, you may print this letter, sign in the space provided above, and return the signed copy to the Company Secretary at the company&rsquo;s registered address.</p>
<div class="footer">
Issued in terms of section 71 of the Companies Act 71 of 2008
</div>
</body></html>`;
}
function signedLetterHtml(
director: Record<string, unknown>,
company: Record<string, unknown>,
date: string,
signatureBase64: string,
signUrl: string
): string {
const esc = (s: string) => String(s ?? "")
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
const dir = {
name: esc([director.first_names, director.surname].filter(Boolean).join(" ")),
id: esc(String(director.id_no ?? "")),
addr1: esc(String(director.residential_address_line_1 ?? "")),
addr2: esc(String(director.residential_address_line_2 ?? "")),
town: esc(String(director.residential_town_city ?? "")),
province: esc(String(director.residential_province ?? "")),
zip: esc(String(director.residential_postal_code ?? "")),
};
const co = {
name: esc(String(company.enterprise_name ?? "")),
p1: esc(String(company.physical_address_line_1 ?? "")),
p2: esc(String(company.physical_address_line_2 ?? "")),
pt: esc(String(company.physical_town_city ?? "")),
pp: esc(String(company.physical_province ?? "")),
pz: esc(String(company.physical_postal_code ?? "")),
};
const d = new Date(date);
const dayOfMonth = d.getUTCDate();
const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const monthName = monthNames[d.getUTCMonth()];
const year = d.getUTCFullYear();
const hasSig = !!signatureBase64;
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Letter of Resignation</title>
<style>
@page { size: A4; margin: 25mm; }
body { font-family: Arial, sans-serif; font-size: 11pt; color: #000; line-height: 1.2; margin: 0; }
.top-block { margin: 0; padding: 0; }
.addressee-block { margin: 0; padding: 0; line-height: 1.4; text-align: left; }
.hdr { font-weight: bold; margin: 14pt 0 4pt 0; }
p { margin: 6pt 0; text-align: justify; }
.sign-line { border-top: 1px solid #000; padding-top: 4pt; width: 60%; margin-top: 4pt; }
.sig-img { height: 60pt; display: block; margin: 0; }
.footer { margin-top: 24pt; font-size: 9pt; color: #555; text-align: center; font-style: italic; }
.signature-block { text-align: left; margin-top: 28pt; }
.signature-name { margin-top: 4pt; }
table { border-collapse: collapse; }
</style></head>
<body>
<table class="top-block" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align:left;width:35%;vertical-align:top;">&nbsp;</td>
<td style="text-align:right;width:65%;vertical-align:top;"><strong>${dir.name}</strong><br/>
${dir.addr1 ? `${dir.addr1}<br/>` : ``}
${dir.addr2 ? `${dir.addr2}<br/>` : ``}
${dir.town ? `${dir.town}<br/>` : ``}
${dir.province ? `${dir.province}<br/>` : ``}
${dir.zip ? `${dir.zip}<br/>` : ``}
</td>
</tr>
</table>
<div class="addressee-block"><strong>The Board</strong><br/>
${co.name}<br/>
${co.p1 ? `${co.p1}<br/>` : ``}${co.p2 ? `${co.p2}<br/>` : ``}
${co.pt ? `${co.pt}<br/>` : ``}
${co.pp ? `${co.pp}<br/>` : ``}
${co.pz ? `${co.pz}<br/>` : ``}
</div>
<div class="hdr">Dear Members of The Board</div>
<p>Please accept this letter as formal notification that I am resigning from my position as Director of the Company, effective immediately, in accordance with the provisions of the Companies Act 71 of 2008 (as amended).</p>
<p>I acknowledge that I have no claim for compensation for loss of office or otherwise against the Company, its officers, or its employees. I further acknowledge that, from this date forward, I am no longer bound by the duties, responsibilities, and liabilities of this office as defined by the Act, common law, and any other applicable legislation.</p>
<p>Please confirm receipt of this letter and the effective date of my resignation.</p>
<p>Signed at ${dir.town || "_____________"} on this ${dayOfMonth} day of ${monthName} ${year}.</p>
<div class="signature-block">
${hasSig
? `<img class="sig-img" src="data:image/png;base64,${signatureBase64}" alt="Signature"/>`
: `<div class="sign-line">&nbsp;</div>`}
<div class="sign-line">&nbsp;</div>
<p class="signature-name">
<strong>${dir.name}</strong><br/>
ID ${dir.id}
</p>
</div>
<div class="footer">
Filed in terms of section 71 of the Companies Act 71 of 2008
</div>
</body></html>`;
}