// 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>("ysterbaard_directors", directorId); const company = await fetchRecord>("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( "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 { 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 `

Dear ${directorName},

Please find attached your Letter of Resignation for ${companyName}.

The letter sets out your formal resignation in accordance with the Companies Act 71 of 2008.

How to complete this:

1. Open the attached PDF.
2. Sign and date the signature block.
3. Email the signed PDF back to us at sj@dyfins.com.

Yours sincerely,
${directorName}

`; } async function sendViaHimalaya(eml: string): Promise { return new Promise((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, "'"); 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 ` Letter of Resignation
  ${dir.name}
${dir.addr1 ? `${dir.addr1}
` : ``} ${dir.addr2 ? `${dir.addr2}
` : ``} ${dir.town ? `${dir.town}
` : ``} ${dir.province ? `${dir.province}
` : ``} ${dir.zip ? `${dir.zip}
` : ``} ${dir.email ? `${dir.email}
` : ``} ${dir.cell ? `${dir.cell}
` : ``}  
${opts.letterDate}
The Board
${co.name}
${co.p1 ? `${co.p1}
` : ``}${co.p2 ? `${co.p2}
` : ``} ${co.pt ? `${co.pt}
` : ``} ${co.pp ? `${co.pp}
` : ``} ${co.pz ? `${co.pz}
` : ``}
Dear Members of The Board

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).

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.

Please confirm receipt of this letter and the effective date of my resignation.

Signed at ${dir.town || "_____________"} on this ${dayOfMonth} day of ${monthName} ${year}.

 

${dir.name}
ID ${dir.id}

`; }