- New /Volumes/KIOXIA/.hermes/profiles/ysterbaard/scripts/pdfd.js standalone HTML->PDF daemon on localhost:3070, calls soffice via execSync (proven working pattern from resignation form watcher.js) - app/api/appointments/route.ts generates acceptance-letter.pdf via daemon, attaches via MIME multipart/mixed - app/api/appointments/[id]/route.ts renders signed-letter.pdf with embedded signature, attaches to distro mail - Idempotency guard: skip distro if status is already mail_sent_to_all
292 lines
No EOL
12 KiB
TypeScript
292 lines
No EOL
12 KiB
TypeScript
// app/api/appointments/[id]/route.ts
|
|
// GET — single appointment (by id)
|
|
// PATCH — update status / upload signature / mark mail_sent_to_all
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { fetchRecord, updateRecord, updateRecordMultipart } from "@/lib/pb";
|
|
import type { YsterbaardDirectorAppointment } from "@/lib/types";
|
|
import { writeFileSync, unlinkSync } from "fs";
|
|
import { spawn } from "child_process";
|
|
|
|
const PDFD_URL = process.env.PDFD_URL || "http://127.0.0.1:3070";
|
|
|
|
export async function GET(
|
|
_req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const { id } = await params;
|
|
const record = await fetchRecord<YsterbaardDirectorAppointment>(
|
|
"ysterbaard_director_appointments",
|
|
id,
|
|
"director,company"
|
|
);
|
|
return NextResponse.json(record);
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return NextResponse.json({ error: msg }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function PATCH(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
const { id } = await params;
|
|
const body = await req.json();
|
|
|
|
// Signature upload — multipart
|
|
if (body.signature_base64) {
|
|
// Idempotency: if distro already ran, don't resend on retry.
|
|
const existing = await fetchRecord<YsterbaardDirectorAppointment>(
|
|
"ysterbaard_director_appointments",
|
|
id
|
|
);
|
|
if (existing.status === "mail_sent_to_all") {
|
|
return NextResponse.json({ ok: true, already: true });
|
|
}
|
|
|
|
// Save signature to PB first (so we don't lose the user's input on distro fail)
|
|
const buf = Buffer.from(body.signature_base64, "base64");
|
|
const sigFile = new File([buf], "signature.png", { type: "image/png" });
|
|
const record = await updateRecordMultipart<YsterbaardDirectorAppointment>(
|
|
"ysterbaard_director_appointments",
|
|
id,
|
|
{ status: "signature_received" },
|
|
[{ field: "signature", file: sigFile }]
|
|
);
|
|
|
|
// Distro email to all directors + company (PDF with embedded signature attached)
|
|
try {
|
|
await sendSignedDistro({ id, signatureBase64: body.signature_base64 });
|
|
await updateRecord("ysterbaard_director_appointments", id, { status: "mail_sent_to_all" });
|
|
} catch (distroErr) {
|
|
console.error("Distro failed (signature saved):", distroErr);
|
|
// Don't fail the request — signature is saved, distro is a follow-up
|
|
}
|
|
|
|
return NextResponse.json(record);
|
|
}
|
|
|
|
// Simple status update
|
|
const updates: Record<string, unknown> = {};
|
|
if (body.status) updates.status = body.status;
|
|
if (body.letter_url) updates.letter = body.letter_url;
|
|
|
|
const record = await updateRecord<YsterbaardDirectorAppointment>(
|
|
"ysterbaard_director_appointments",
|
|
id,
|
|
updates
|
|
);
|
|
return NextResponse.json(record);
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return NextResponse.json({ error: msg }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
// ─── Signed-letter PDF via daemon ─────────────────────────────────────────
|
|
|
|
function signedLetterHtml(directorName: string, companyName: string, date: string, signatureBase64: string): string {
|
|
return `<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><title>Signed Letter of Appointment</title>
|
|
<style>
|
|
@page { size: A4; margin: 25mm; }
|
|
body { font-family: Arial, sans-serif; font-size: 11pt; color: #111; line-height: 1.5; }
|
|
h1 { text-align: center; font-size: 14pt; margin: 0 0 6pt; }
|
|
h2 { font-size: 11pt; margin: 14pt 0 4pt; border-bottom: 1px solid #333; padding-bottom: 2pt; }
|
|
p { margin: 6pt 0; text-align: justify; }
|
|
.meta { font-size: 10pt; color: #444; margin-bottom: 18pt; }
|
|
.sig-block { margin-top: 28pt; border-top: 1px solid #000; padding-top: 8pt; width: 60%; }
|
|
.sig-img { height: 70pt; display: block; margin: 4pt 0; }
|
|
.sig-name { font-weight: bold; }
|
|
.sig-role { color: #555; font-size: 10pt; }
|
|
.footer { margin-top: 32pt; font-size: 9pt; color: #666; text-align: center; }
|
|
</style></head>
|
|
<body>
|
|
<h1>SIGNED LETTER OF APPOINTMENT AS DIRECTOR</h1>
|
|
<p class="meta">Date: ${date}</p>
|
|
|
|
<p><strong>To:</strong> All Directors and the Company Secretary</p>
|
|
<p><strong>Re:</strong> Acceptance of Director Appointment — <em>${companyName}</em></p>
|
|
|
|
<h2>1. Confirmation</h2>
|
|
<p>
|
|
We are pleased to confirm that <strong>${directorName}</strong> has accepted the
|
|
appointment as a Director of <strong>${companyName}</strong>.
|
|
</p>
|
|
|
|
<h2>2. Acceptance</h2>
|
|
<p>
|
|
The acceptance was received on <strong>${date}</strong> and is hereby recorded in
|
|
terms of section 70 of the Companies Act 71 of 2008.
|
|
</p>
|
|
|
|
<h2>3. Signature</h2>
|
|
<p>The Director's acceptance is evidenced by the signature below:</p>
|
|
|
|
<div class="sig-block">
|
|
<img class="sig-img" src="data:image/png;base64,${signatureBase64}" alt="Signature"/>
|
|
<p class="sig-name">${directorName}</p>
|
|
<p class="sig-role">Director — ${companyName}</p>
|
|
</div>
|
|
|
|
<p class="footer">
|
|
Filed by the Board of Directors — ${companyName}<br/>
|
|
Section 70, Companies Act 71 of 2008
|
|
</p>
|
|
</body></html>`;
|
|
}
|
|
|
|
async function renderPdf(html: string, filename: string): Promise<Buffer> {
|
|
const res = await fetch(`${PDFD_URL}/pdf`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ html, filename }),
|
|
});
|
|
if (!res.ok) throw new Error(`pdfd failed: ${res.status} ${await res.text()}`);
|
|
const data = (await res.json()) as { pdf: string };
|
|
return Buffer.from(data.pdf, "base64");
|
|
}
|
|
|
|
// ─── Send signed letter to all directors + company ─────────────────────────
|
|
|
|
async function sendSignedDistro(opts: { id: string; signatureBase64: string }) {
|
|
const appt = await fetchRecord<YsterbaardDirectorAppointment>(
|
|
"ysterbaard_director_appointments",
|
|
opts.id,
|
|
"director,company"
|
|
);
|
|
|
|
const director = appt.expand?.director;
|
|
const company = appt.expand?.company;
|
|
const directorName = [director?.first_names, director?.surname].filter(Boolean).join(" ") || "The Director";
|
|
const companyName = company?.enterprise_name || "the Company";
|
|
const date = new Date().toISOString().split("T")[0];
|
|
|
|
// Build recipient list: signing director + all other directors + company email
|
|
const recipients: string[] = [];
|
|
if (director?.email) recipients.push(director.email);
|
|
const { fetchDirectorsForCompany } = await import("@/lib/pb");
|
|
const junctions = await fetchDirectorsForCompany(company!.id) as Array<{
|
|
expand?: { director?: { email?: string } };
|
|
}>;
|
|
for (const j of junctions) {
|
|
const em = j.expand?.director?.email;
|
|
if (em) recipients.push(em);
|
|
}
|
|
const companyEmail = (company as { company_email?: string } | undefined)?.company_email;
|
|
if (companyEmail && companyEmail.includes("@")) recipients.push(companyEmail);
|
|
// Operator fallback — if no other recipients, copy SJ so we know it happened
|
|
const otherThanSigning = recipients.filter((e) => e !== director?.email);
|
|
if (otherThanSigning.length === 0) recipients.push("SJ@dyfins.com");
|
|
|
|
const uniqueEmails = [...new Set(recipients.filter(Boolean))];
|
|
if (uniqueEmails.length === 0) {
|
|
console.error("sendSignedDistro: no recipients");
|
|
return;
|
|
}
|
|
|
|
// Build the signed PDF once, reuse for every recipient
|
|
let pdfBytes: Buffer | null = null;
|
|
let pdfFilename = `signed-acceptance-${Date.now()}.pdf`;
|
|
try {
|
|
pdfBytes = await renderPdf(
|
|
signedLetterHtml(directorName, companyName, date, opts.signatureBase64),
|
|
`signed-acceptance-${companyName.replace(/[^A-Za-z0-9]/g, "_")}`
|
|
);
|
|
} catch (e) {
|
|
console.error("PDF generation failed (sending HTML-only):", e instanceof Error ? e.message : e);
|
|
}
|
|
|
|
for (const to of uniqueEmails) {
|
|
await sendSignedEmail({
|
|
to,
|
|
directorName,
|
|
companyName,
|
|
date,
|
|
signatureBase64: opts.signatureBase64,
|
|
pdfBytes,
|
|
pdfFilename,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function sendSignedEmail(opts: {
|
|
to: string;
|
|
directorName: string;
|
|
companyName: string;
|
|
date: string;
|
|
signatureBase64: string;
|
|
pdfBytes: Buffer | null;
|
|
pdfFilename: string;
|
|
}) {
|
|
const { to, directorName, companyName, date, signatureBase64, pdfBytes, pdfFilename } = opts;
|
|
const subject = `${directorName} accepted the appointment as director of ${companyName}`;
|
|
|
|
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\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\r\n`;
|
|
}
|
|
eml += signedEmailHtml(directorName, companyName, date, signatureBase64);
|
|
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`;
|
|
eml += pdfBytes.toString("base64").replace(/(.{76})/g, "$1\r\n");
|
|
eml += `\r\n`;
|
|
eml += `--${boundary}--\r\n`;
|
|
}
|
|
|
|
const emlPath = `/tmp/signed-email-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.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.write(eml);
|
|
himalaya.stdin.end();
|
|
let err = "";
|
|
himalaya.stderr.on("data", (d) => { err += d.toString(); });
|
|
await new Promise<void>((res) => { himalaya.on("close", () => res()); });
|
|
if (err.trim()) console.error("himalaya stderr:", err.trim());
|
|
} finally {
|
|
try { unlinkSync(emlPath); } catch {}
|
|
}
|
|
}
|
|
|
|
function signedEmailHtml(directorName: string, companyName: string, date: string, signatureBase64: 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;">Director Acceptance Recorded</h2>
|
|
<p>I am glad to inform you that <strong>${directorName}</strong> accepted the appointment as director of <strong>${companyName}</strong>.</p>
|
|
<p>Date: ${date}</p>
|
|
<div style="background:#fff;border:1px solid #eee;border-radius:6px;padding:20px;margin:20px 0;">
|
|
<h3 style="font-size:14px;text-align:center;margin:0 0 12px;">LETTER OF APPOINTMENT AS DIRECTOR</h3>
|
|
<hr style="border:none;border-top:1px solid #333;margin:8px 0 16px;" />
|
|
<p>We are pleased to confirm that <strong>${directorName}</strong> has been appointed as a Director of <strong>${companyName}</strong>.</p>
|
|
<p>The appointment was accepted on ${date} and is hereby recorded in terms of section 70 of the Companies Act 71 of 2008.</p>
|
|
<div style="margin-top:24px;border-top:1px solid #333;padding-top:8px;width:260px;">
|
|
<p style="margin:0;"><strong>${directorName}</strong></p>
|
|
<p style="margin:0;color:#555;">Director</p>
|
|
<p style="margin-top:8px;"><img src="data:image/png;base64,${signatureBase64}" alt="Signature" style="height:60px;display:block;" /></p>
|
|
</div>
|
|
<p style="margin-top:16px;font-size:12px;color:#666;">The full signed letter is attached as a PDF.</p>
|
|
</div></div></body></html>`;
|
|
} |