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

398 lines
17 KiB
TypeScript

// app/api/resignations/route.ts
// GET — list all director resignations (with expand)
// POST — create a new resignation, generate letter PDF, email director
import { NextRequest, NextResponse } from "next/server";
import { fetchCollection, createRecord, fetchRecord } 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 || "";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const data = await fetchCollection("ysterbaard_director_resignations", {
perPage: 500,
sort: "-sent_at",
expand: "director,company",
});
return NextResponse.json(data);
} catch (err: unknown) {
console.error("GET /api/resignations:", err);
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
interface PostBody {
director_id?: string;
company_id?: string;
proceed_anyway?: boolean;
}
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as PostBody;
const directorId = body.director_id;
const companyId = body.company_id;
const proceedAnyway = !!body.proceed_anyway;
if (!directorId || !companyId) {
return NextResponse.json({ error: "director_id and company_id required" }, { status: 400 });
}
// Fetch full records
const director = await fetchRecord<Record<string, unknown>>("ysterbaard_directors", directorId);
const company = await fetchRecord<Record<string, unknown>>("ysterbaard_companies", companyId);
const directorName =
[director.first_names, director.surname].filter(Boolean).join(" ") || "Director";
const companyName = String(company.enterprise_name ?? "the Company");
// Pick addresses (real if structured not test data)
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
);
// Recipients — pick first director email (their own email), then company email
const directorEmail = String(director.email ?? "");
const companyEmail = String(company.company_email ?? "");
const recipientTo = directorEmail || "sj@dyfins.com";
const missing = [
!directorEmail ? "director.email" : "",
!companyEmail ? "company.company_email" : "",
].filter(Boolean);
const signToken = makeToken();
// Create record first
const record = await createRecord<YsterbaardDirectorResignation>(
"ysterbaard_director_resignations",
{
director: directorId,
company: companyId,
status: "pending",
sent_to: recipientTo,
sign_token: signToken,
missing_emails: missing.join(", "),
proceed_anyway: proceedAnyway,
}
);
// Build letter HTML. NOTE: no signing link — the app's host is not
// reachable from a director's machine, so the letter is a PDF they
// print, sign, and return by email.
const today = new Date().toISOString().split("T")[0];
const html = letterHtml({
directorName,
directorIdNo: String(director.id_no ?? ""),
directorEmail,
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,
letterDate: today,
});
// Render PDF
let pdfBytes: Buffer | null = null;
let pdfFilename = `resignation-letter-${companyName.replace(/[^A-Za-z0-9]/g, "_")}.pdf`;
try {
const rendered = await renderPdf(html, `resignation-letter-${companyName.replace(/[^A-Za-z0-9]/g, "_")}`);
pdfBytes = rendered;
if (pdfBytes) {
// Update record with PDF via multipart (file upload requires multipart, not JSON)
const { updateRecordMultipart } = await import("@/lib/pb");
await updateRecordMultipart(
"ysterbaard_director_resignations",
record.id,
{
status: "mail_sent",
sent_at: new Date().toISOString(),
},
[{ field: "letter", file: new File([pdfBytes], pdfFilename, { type: "application/pdf" }) }]
);
} else {
// Still stamp the record so the Resolutions table reflects the send.
const { updateRecord } = await import("@/lib/pb");
await updateRecord("ysterbaard_director_resignations", record.id, {
status: "mail_sent",
sent_at: new Date().toISOString(),
});
}
} catch (pdfErr) {
console.error("PDF render or upload failed:", pdfErr);
const { updateRecord } = await import("@/lib/pb");
await updateRecord("ysterbaard_director_resignations", record.id, {
status: "mail_sent",
sent_at: new Date().toISOString(),
});
}
// Send email
try {
const eml = buildInitialEml({
to: recipientTo,
directorName,
companyName,
pdfBytes,
pdfFilename,
});
await sendViaHimalaya(eml);
} catch (mailErr) {
const detail = mailErr instanceof Error ? mailErr.message : String(mailErr);
console.error("[resignations] INITIAL MAIL FAILED — letter saved but NOT delivered:", detail);
// 502, not 200. The previous 200 told the client everything was fine,
// so the UI showed "Letter sent successfully" while nothing had been
// delivered. The record IS still returned so the UI can keep it and
// offer a resend.
return NextResponse.json({
ok: false,
error: "Letter saved but the email could not be sent.",
warning: "Letter saved but email failed",
detail,
record,
}, { status: 502 });
}
return NextResponse.json({ ok: true, record });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error("POST /api/resignations:", msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
// ─── Helpers ───────────────────────────────────────────────────────────────
function makeToken(): string {
return [...Array(20)].map(() => Math.floor(Math.random() * 36).toString(36)).join("");
}
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);
if (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 it can't be produced.
*
* Delegates to lib/pdf-render-letter, which uses the external pdfd service
* when one is reachable and otherwise renders locally with Chrome/Edge
* headless. The pdfd service was never ported off the Mac, so on this PC
* the local browser is what actually produces the attachment.
*
* Never throws: a missing attachment must not cost the director the
* letter itself. */
async function renderPdf(html: string, filename: string): Promise<Buffer | null> {
const { renderLetterPdf } = await import("@/lib/pdf-render-letter");
return renderLetterPdf(html, filename);
}
function buildInitialEml(opts: {
to: string; directorName: string; companyName: 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);
if (opts.pdfBytes) {
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): 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>
<p>The letter sets out your formal resignation in accordance with the Companies Act 71 of 2008.</p>
<div style="background:#fff;border:1px solid #eee;border-radius:6px;padding:20px;margin:20px 0;">
<p><strong>How to complete this:</strong></p>
<p>1. Open the attached PDF.<br/>
2. Sign and date the signature block.<br/>
3. Email the signed PDF back to us at <strong>sj@dyfins.com</strong>.</p>
</div>
<p>Yours sincerely,<br/><strong>${directorName}</strong></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();
});
}
// ─── Letter HTML — mirrors the docx template ──────────────────────────────
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;
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; }
.re { margin: 8pt 0 12pt 0; font-weight: bold; }
p { margin: 6pt 0; text-align: justify; }
.sign-line { border-top: 1px solid #000; padding-top: 4pt; width: 60%; margin-top: 4pt; }
.signature-block { text-align: left; margin-top: 28pt; }
.signature-name { 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; }
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.addr1}<br/>` : ``}
${dir.addr2 ? `${dir.addr2}<br/>` : ``}
${dir.town ? `${dir.town}<br/>` : ``}
${dir.province ? `${dir.province}<br/>` : ``}
${dir.zip ? `${dir.zip}<br/>` : ``}
${dir.email ? `${dir.email}<br/>` : ``}
${dir.cell ? `${dir.cell}<br/>` : ``}
&nbsp;<br/>
${opts.letterDate}
</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="footer">
Issued in terms of section 71 of the Companies Act 71 of 2008
</div>
</body></html>`;
}