WORKFLOW-APP/app/api/appointments/route.ts
Ysterbaard 1fecf02214 Add pre-send validation gate + idempotent sign-back to appointment flow
PB: 3 new fields on ysterbaard_director_appointments
  - proceed_anyway (bool) — true if user proceeded with missing emails
  - missing_emails (text) — comma-separated keys
  - missing_emails_detail (text) — JSON per-recipient validation

App:
  - POST /api/appointments: builds recipient report (initial +
    distro); returns 422 + report if proceed_anyway is missing
    and any recipient is missing/invalid. preview=1 returns the
    report without sending.
  - PATCH /api/appointments/[id]: tightened idempotency guard to
    also short-circuit on signature_received (not just
    mail_sent_to_all). Prevents double-send on double-click.
  - DirectorAppointmentTab: two-step modal — "Validate recipients"
    surfaces the report (status dots + per-recipient email),
    then Send / Proceed anyway / Fix records first. New
    "company_email" gap surfaced before any email leaves.
  - lib/format.ts: isValidEmail() shared helper.

End-to-end tested: 422 enforces server-side, preview returns
the report, double-PATCH signs only once.
2026-08-18 21:29:08 +02:00

511 lines
No EOL
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// app/api/appointments/route.ts
// GET — list all director appointments (with expand)
// POST — create a new appointment, generate letter PDF, email director
import { NextRequest, NextResponse } from "next/server";
import { fetchCollection, createRecord, updateRecordMultipart } from "@/lib/pb";
import { isValidEmail } from "@/lib/format";
import { writeFileSync, unlinkSync } from "fs";
import { spawn } from "child_process";
export const dynamic = "force-dynamic";
const PDFD_URL = process.env.PDFD_URL || "http://127.0.0.1:3070";
// GET /api/appointments
export async function GET() {
try {
const data = await fetchCollection("ysterbaard_director_appointments", {
perPage: 500,
sort: "-created",
expand: "director,company",
});
return NextResponse.json(data);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
// POST /api/appointments
export async function POST(req: NextRequest) {
try {
// Support preview mode: validate without sending.
// Caller does GET-style fetch with ?preview=1 to get a recipient report.
const preview = req.nextUrl.searchParams.get("preview") === "1";
const body = await req.json();
const {
director_id,
company_id,
director_email,
director_cell,
director_name,
director_surname,
company_name,
proceed_anyway = false,
} = body;
if (!director_id || !company_id) {
return NextResponse.json({ error: "director_id and company_id are required" }, { status: 400 });
}
const signToken = Math.random().toString(36).substring(2) + Date.now().toString(36);
const today = new Date().toISOString().split("T")[0];
const emailTo = director_email || "SJ@Dyfins.com";
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const signUrl = `${appUrl}/sign/${signToken}`;
// Resolve full director + company records so the letter has all the
// structured address fields (added 2026-08-18). The body fields given by
// the tab are partial; the renderer needs the full records.
const { fetchRecord } = await import("@/lib/pb");
const fullDirector = await fetchRecord<Record<string, unknown>>(
"ysterbaard_directors",
director_id
);
const fullCompany = await fetchRecord<Record<string, unknown>>(
"ysterbaard_companies",
company_id
);
const fullName =
[fullDirector.first_names, fullDirector.surname].filter(Boolean).join(" ") ||
[director_name, director_surname].filter(Boolean).join(" ") ||
"Director";
const companyName =
fullCompany.enterprise_name || company_name || "the Company";
// Build recipient validation report for the initial send + the eventual
// signed-distro. The validation gate is the responsibility of the tab,
// but we surface the same report here so the server can refuse to send
// if proceed_anyway is false and any address is missing/invalid.
const { fetchDirectorsForCompany } = await import("@/lib/pb");
const initialRecipients = [{
key: "new_director",
label: "New director (initial send)",
email: fullDirector.email ?? "",
role: "to",
}];
const distroRecipients: Array<{ key: string; label: string; email: string; role: string }> = [];
const junctions = await fetchDirectorsForCompany(fullCompany.id) as Array<{
id?: string;
expand?: { director?: { id?: string; first_names?: string; surname?: string; email?: string } };
}>;
for (const j of junctions) {
const d = j.expand?.director;
if (!d) continue;
const name = [d.first_names, d.surname].filter(Boolean).join(" ") || d.id || "Unknown";
distroRecipients.push({
key: `director:${d.id ?? j.id}`,
label: `Director: ${name}`,
email: d.email ?? "",
role: "to",
});
}
distroRecipients.push({
key: "company_email",
label: `Company (${fullCompany.enterprise_name || company_name || "Unknown"})`,
email: fullCompany.company_email ?? "",
role: "to",
});
const validate = (rs: Array<{ key: string; label: string; email: string; role: string }>) =>
rs.map((r) => {
const ok = isValidEmail(r.email);
return { ...r, status: ok ? ("valid") : (r.email.trim() === "" ? "missing" : "invalid") };
});
const initialReport = validate(initialRecipients);
const distroReport = validate(distroRecipients);
const allRecipients = [...initialReport, ...distroReport];
const problemRecipients = allRecipients.filter((r) => r.status !== "valid");
// Preview mode: return the report, do not send.
if (preview) {
return NextResponse.json({
preview: true,
initial: initialReport,
distro: distroReport,
problems: problemRecipients.length,
});
}
// Server-side guard: refuse to send if there are problems and the caller
// did not explicitly proceed. The tab UI is the primary gate; this is
// defence-in-depth in case the gate is bypassed.
if (problemRecipients.length > 0 && !proceed_anyway) {
return NextResponse.json(
{
error: "Recipient validation failed. The tab should have surfaced this; pass proceed_anyway=true to override.",
preview: false,
initial: initialReport,
distro: distroReport,
},
{ status: 422 }
);
}
// 1. Create appointment record (with audit log fields)
const record = await createRecord("ysterbaard_director_appointments", {
director: director_id,
company: company_id,
status: "pending",
sent_to: emailTo,
sent_at: today,
sign_token: signToken,
proceed_anyway: problemRecipients.length > 0,
missing_emails: problemRecipients.map((r) => r.key).join(","),
missing_emails_detail: JSON.stringify(
problemRecipients.map((r) => ({ key: r.key, label: r.label, email: r.email, status: r.status })),
null,
0
),
});
// 2. Send HTML email to director with sign link + PDF attachment
await sendAppointmentEmail({
to: emailTo,
directorName: fullName,
companyName,
signUrl,
directorRecord: fullDirector,
companyRecord: fullCompany,
});
// 3. Update status to mail_sent
await updateRecordMultipart("ysterbaard_director_appointments", record.id, { status: "mail_sent" }, []);
return NextResponse.json({ id: record.id, sign_token: signToken }, { status: 201 });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error("Appointment POST error:", msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
// ─── Letter HTML → PDF via daemon ─────────────────────────────────────────
// Consent to Act as Director — letter template (per SJ docx 2026-08-18)
// Drawn from "Resolution - Consent_to_Act_as_Director_ _1_.docx". When
// signatureBase64 is empty, the docx prints a blank signature line; when
// present, the captured signature is embedded and the letter is page 1 of
// the audio. The same template renders both the initial and the signed copy.
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;
signatureBase64?: string;
letterDate?: string;
}): string {
const o = opts;
const today = new Date().toISOString().split("T")[0];
const date = o.letterDate || today;
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 fmtDate = `${dayOfMonth} ${monthName} ${year}`;
// Escape helper (defensive — names occasionally contain & or <)
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(o.directorName),
id: esc(o.directorIdNo),
email: esc(o.directorEmail),
cell: esc(o.directorCellPhone),
addr1: esc(o.residentialAddressLine1),
addr2: esc(o.residentialAddressLine2),
town: esc(o.residentialTownCity),
province: esc(o.residentialProvince),
zip: esc(o.residentialPostalCode),
};
const co = {
name: esc(o.companyName),
p1: esc(o.physicalAddressLine1),
p2: esc(o.physicalAddressLine2),
pt: esc(o.physicalTownCity),
pp: esc(o.physicalProvince),
pz: esc(o.physicalPostalCode),
};
return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Consent to Act as Director</title>
<style>
@page { size: A4; margin: 25mm; }
body { font-family: Arial, sans-serif; font-size: 11pt; color: #000; line-height: 1.4; margin: 0; }
.top-block { margin: 0 0 18pt 0; }
.top-right { float: right; text-align: left; min-width: 220pt; margin-left: 16pt; margin-bottom: 12pt; }
.director-name { font-weight: bold; font-size: 11pt; margin-top: 4pt; }
.top-left { text-align: left; }
.addressee-name { font-weight: bold; margin-top: 12pt; }
.addressee-block { margin: 12pt 0 16pt 0; }
.hdr { font-weight: bold; margin: 14pt 0 4pt 0; }
.re { margin: 8pt 0 12pt 0; font-weight: bold; }
.ol { margin: 4pt 0 4pt 16pt; padding: 0; }
.ol li { margin: 2pt 0; }
.sign-line { border-top: 1px solid #000; padding-top: 4pt; width: 60%; margin-top: 28pt; }
.sig-img { height: 60pt; display: block; margin: 8pt 0; }
.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; }
p { margin: 6pt 0; }
</style></head>
<body>
<div class="top-block">
<div class="top-right">
<div>${dir.name}</div>
<div>${dir.addr1}</div>
${dir.addr2 ? `<div>${dir.addr2}</div>` : ``}
<div>${[dir.town, dir.zip].filter(Boolean).join(", ")}</div>
${dir.province ? `<div>${dir.province}</div>` : ``}
<div>${dir.email}</div>
<div>${dir.cell}</div>
</div>
<div class="top-left">${date}</div>
<div style="clear:both;"></div>
</div>
<div class="addressee-block">
<div class="addressee-name">The Board</div>
<div>${co.name}</div>
<div>${co.p1}</div>
${co.p2 ? `<div>${co.p2}</div>` : ``}
<div>${[co.pt, co.pz].filter(Boolean).join(", ")}</div>
${co.pp ? `<div>${co.pp}</div>` : ``}
</div>
<div class="hdr">Dear Members of The Board</div>
<div class="re">RE: CONSENT TO ACT AS DIRECTOR</div>
<p>It is with great pleasure that I accept your offer to serve as Director of the Company in accordance with the provisions of The Companies Act 71 of 2008 (as amended) ("the Act").</p>
<p class="hdr">Eligibility to Act as Director</p>
<p>A person is ineligible to be a director if the person:</p>
<ol class="ol">
<li>is a juristic person;</li>
<li>is an unemancipated minor, or is under a similar legal disability; or</li>
<li>does not satisfy all of qualifications as set out in the Company’s Memorandum of Incorporation ("MOI").</li>
</ol>
<p>Section 69(7) of the Act.</p>
<p>A person is disqualified to be a director if the person:</p>
<ol class="ol">
<li>has been prohibited by a court to be a director or declared to be delinquent;</li>
<li>is an unrehabilitated insolvent; is prohibited in terms of any public regulation to be a director of the Company; has been removed from office on the grounds of misconduct involving dishonesty; or has been convicted and imprisoned without the option of a fine or fined more than the prescribed amount for theft, forgery, perjury or any offence involving fraud, misrepresentation or dishonesty in connection with the promotion, formation or management of a company.</li>
</ol>
<p>Section 69(8) of the Act.</p>
<p class="hdr">Disclosure of Personal Financial Interests</p>
<p>A personal financial interest means a direct material interest of financial, monetary, or economic nature, or to which a monetary value may be attributed, but does not include any interest held in a unit trust or collective investment scheme.</p>
<p>A director may at any time disclose a personal financial interest to the Board or the shareholders by delivering a written notice, setting out the nature and extent of that interest.</p>
<p>Section 75(4) of the Act.</p>
<p>If a director has or knows of a related person with a personal financial interest in respect to a matter being considered by the Board, they are obligated to disclose the general nature of that interest and any material information relating to the matter.</p>
<p>Section 75(5) of the Act.</p>
<p class="hdr">Duties and Responsibilities</p>
<p>A director must not use their position, or any information obtained while acting in that capacity to:</p>
<ol class="ol">
<li>(a) gain an advantage for themselves or any other person other than the Company or a wholly owned subsidiary of the Company; or knowingly cause harm to the Company or a subsidiary of the Company; and</li>
<li>(b) A director must communicate to the Board any information that comes to their attention, unless they: reasonably believe the information is immaterial, generally available to the public or known to the other directors; or are bound not to disclose that information by a legal or ethical obligation of confidentiality.</li>
</ol>
<p>Section 76(2) of the Act.</p>
<p>A director must also exercise their powers and perform their functions:</p>
<ol class="ol">
<li>in good faith and for a proper purpose;</li>
<li>in the best interests of the Company; and</li>
<li>with the degree of care, skill and diligence that may reasonably be expected of a director.</li>
</ol>
<p>Section 76(3) of the Act.</p>
<p class="hdr">Liability of a Director</p>
<p>A director may be held liable for any loss, damages or costs sustained by the Company in accordance with the principles of Common Law relating to:</p>
<ol class="ol">
<li>Fiduciary duty, in the case of a breach of duty as contemplated in Section 75, Section 76(2), Section 76(3)(a) or Section 76(3)(b); or</li>
<li>delict, in the case of a breach of duty as contemplated in Section 76(3)(c), any provision of the Act not mentioned in Section 77 or any provision of the MOI.</li>
</ol>
<p>Section 77(2) of the Act.</p>
<p>A director is also liable for any loss, damages or costs sustained by the Company as a direct or indirect consequence of them having:</p>
<ol class="ol">
<li>acted in the name of the Company, despite knowing they lacked the authority to do so;</li>
<li>carried on the business of the Company, despite knowing it was being conducted recklessly or with gross negligence as prohibited by Section 22(1);</li>
<li>been party to an act or omission by the Company, despite knowing it was for fraudulent purposes;</li>
<li>signed, consented to or authorised the publication of a false or misleading financial statement or prospectus;</li>
<li>failed to vote against the issuing, granting or provision of any unauthorised shares, securities, options or financial assistance.</li>
</ol>
<p>Section 77(3) of the Act.</p>
<p class="hdr">Consent to Act as Director</p>
<p>I, ${dir.name} (ID ${dir.id}), hereby consent to act as Director of the Company in accordance with the provisions of Section 66(7)(b) of the Act. I confirm that I am eligible, that I am not disqualified, and that I have familiarised myself with, understand and consider myself bound by the duties, responsibilities and liabilities of this office as set out by the Act, Common Law and any other legislation which regulates this office from time to time.</p>
<p>Signed at ${dir.town || "_____________"} on this ${dayOfMonth} day of ${monthName} ${year}.</p>
${o.signatureBase64
? `<img class="sig-img" src="data:image/png;base64,${o.signatureBase64}" alt="Signature"/>`
: `<div class="sign-line">&nbsp;</div>`}
<p>
<strong>${dir.name}</strong><br/>
ID ${dir.id}
</p>
${o.signatureBase64 ? `` : `
<div class="sign-link-box"><strong>To accept electronically, visit:</strong><br/>${o.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’s registered address.</p>
`}
<div class="footer">
Issued in terms of section 66(7)(b) of the Companies Act 71 of 2008
</div>
</body></html>`;
}
async function renderPdf(html: string, filename: string): Promise<{ bytes: Buffer; filename: string }> {
const res = await fetch(`${PDFD_URL}/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html, filename }),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`pdfd failed: ${res.status} ${t}`);
}
const data = (await res.json()) as { ok: boolean; pdf: string; filename: string; bytes: number };
return { bytes: Buffer.from(data.pdf, "base64"), filename: data.filename };
}
// ─── Email ────────────────────────────────────────────────────────────────
async function sendAppointmentEmail(opts: {
to: string;
directorName: string;
companyName: string;
signUrl: string;
directorRecord: Record<string, unknown>;
companyRecord: Record<string, unknown>;
}) {
const { to, directorName, companyName, signUrl, directorRecord, companyRecord } = opts;
const subject = `Consent to act as director — ${companyName}`;
// 1. Generate PDF via daemon
const html = letterHtml({
directorName,
directorIdNo: String(directorRecord.id_no ?? ""),
directorEmail: String(directorRecord.email ?? ""),
directorCellPhone: String(directorRecord.telephone ?? ""),
residentialAddressLine1: String(directorRecord.residential_address_line_1 ?? ""),
residentialAddressLine2: String(directorRecord.residential_address_line_2 ?? ""),
residentialTownCity: String(directorRecord.residential_town_city ?? ""),
residentialProvince: String(directorRecord.residential_province ?? ""),
residentialPostalCode: String(directorRecord.residential_postal_code ?? ""),
companyName,
physicalAddressLine1: String(companyRecord.physical_address_line_1 ?? ""),
physicalAddressLine2: String(companyRecord.physical_address_line_2 ?? ""),
physicalTownCity: String(companyRecord.physical_town_city ?? ""),
physicalProvince: String(companyRecord.physical_province ?? ""),
physicalPostalCode: String(companyRecord.physical_postal_code ?? ""),
signUrl,
});
let pdfBytes: Buffer | null = null;
let pdfFilename = `acceptance-letter-${Date.now()}.pdf`;
try {
const rendered = await renderPdf(html, `acceptance-letter`);
pdfBytes = rendered.bytes;
pdfFilename = rendered.filename;
} catch (e) {
console.error("PDF generation failed (sending HTML-only):", e instanceof Error ? e.message : e);
}
// 2. Build MIME multipart/mixed EML (HTML body + PDF attachment)
const boundary = "----YSTERBAARD_" + Date.now();
let eml = `To: ${to}\r\n`;
eml += `From: SJ@Dyfins.com\r\n`;
eml += `Subject: ${subject}\r\n`;
eml += `MIME-Version: 1.0\r\n`;
if (pdfBytes) {
eml += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n`;
eml += `\r\n`;
eml += `--${boundary}\r\n`;
eml += `Content-Type: text/html; charset="utf-8"\r\n`;
eml += `Content-Transfer-Encoding: 7bit\r\n\r\n`;
} else {
eml += `Content-Type: text/html; charset="utf-8"\r\n`;
eml += `\r\n`;
}
eml += emailBodyHtml(directorName, companyName, signUrl);
eml += `\r\n`;
if (pdfBytes) {
eml += `--${boundary}\r\n`;
eml += `Content-Type: application/pdf; name="${pdfFilename}"\r\n`;
eml += `Content-Transfer-Encoding: base64\r\n`;
eml += `Content-Disposition: attachment; filename="${pdfFilename}"\r\n\r\n`;
// Wrap base64 at 76 chars per RFC 2045
eml += pdfBytes.toString("base64").replace(/(.{76})/g, "$1\r\n");
eml += `\r\n`;
eml += `--${boundary}--\r\n`;
}
const emlPath = `/tmp/appt-email-${Date.now()}.eml`;
writeFileSync(emlPath, eml, "utf8");
try {
const himalaya = spawn(
"/opt/homebrew/bin/himalaya",
["-a", "dyfins", "message", "send", "--save", "Sent"],
{ stdio: ["pipe", "pipe", "pipe"] }
);
himalaya.stdin.on("error", () => {});
himalaya.stdin.write(eml);
himalaya.stdin.end();
let err = "";
let out = "";
himalaya.stderr.on("data", (d) => { err += d.toString(); });
himalaya.stdout.on("data", (d) => { out += d.toString(); });
himalaya.on("error", (e) => { console.error("himalaya spawn error:", e.message); });
await new Promise<void>((res) => { himalaya.on("close", () => res()); });
if (err.trim()) console.error("himalaya stderr:", err.trim());
if (out.trim()) console.log("himalaya stdout:", out.trim());
} finally {
try { unlinkSync(emlPath); } catch {}
}
}
function emailBodyHtml(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>Good day <strong>${directorName}</strong>,</p>
<p>Please find attached the <strong>Consent to Act as Director</strong> letter for <strong>${companyName}</strong>.</p>
<p>The letter sets out your obligations under the Companies Act 71 of 2008 (eligibility, disclosure, duties and liability) and includes the formal consent for you to sign.</p>
<div style="background:#fff;border:1px solid #eee;border-radius:6px;padding:20px;margin:20px 0;">
<p><strong>To accept your appointment, please 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 Consent to Act
</a>
</p>
<p>Or copy this link into your browser:<br/><span style="color:#0066cc;font-size:12px;word-break:break-all;">${signUrl}</span></p>
<p>Please note that until your consent is received, your appointment is not complete.</p>
<p>Yours sincerely,<br/><strong>The Board of ${companyName}</strong></p>
</div></div></body></html>`;
}