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.
398 lines
17 KiB
TypeScript
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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
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;"> </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/>` : ``}
|
|
<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"> </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>`;
|
|
}
|