2026-08-17 16:33:56 +00:00
|
|
|
// app/api/appointments/route.ts
|
|
|
|
|
// GET — list all director appointments (with expand)
|
2026-08-17 20:34:58 +00:00
|
|
|
// POST — create a new appointment, generate letter PDF, email director
|
2026-08-17 16:33:56 +00:00
|
|
|
|
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
|
import { fetchCollection, createRecord, updateRecordMultipart } from "@/lib/pb";
|
2026-08-17 20:34:58 +00:00
|
|
|
import { writeFileSync, unlinkSync } from "fs";
|
2026-08-17 19:45:36 +00:00
|
|
|
import { spawn } from "child_process";
|
2026-08-17 16:33:56 +00:00
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
const PDFD_URL = process.env.PDFD_URL || "http://127.0.0.1:3070";
|
|
|
|
|
|
2026-08-17 16:33:56 +00:00
|
|
|
// 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 {
|
|
|
|
|
const body = await req.json();
|
|
|
|
|
const {
|
|
|
|
|
director_id,
|
|
|
|
|
company_id,
|
|
|
|
|
director_email,
|
|
|
|
|
director_cell,
|
|
|
|
|
director_name,
|
|
|
|
|
director_surname,
|
|
|
|
|
company_name,
|
|
|
|
|
} = 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 fullName = [director_name, director_surname].filter(Boolean).join(" ");
|
|
|
|
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
|
|
|
|
const signUrl = `${appUrl}/sign/${signToken}`;
|
|
|
|
|
|
|
|
|
|
// 1. Create appointment record
|
|
|
|
|
const record = await createRecord("ysterbaard_director_appointments", {
|
|
|
|
|
director: director_id,
|
|
|
|
|
company: company_id,
|
|
|
|
|
status: "pending",
|
|
|
|
|
sent_to: emailTo,
|
|
|
|
|
sent_at: today,
|
|
|
|
|
sign_token: signToken,
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
// 2. Send HTML email to director with sign link + PDF attachment
|
2026-08-17 16:33:56 +00:00
|
|
|
await sendAppointmentEmail({
|
|
|
|
|
to: emailTo,
|
|
|
|
|
directorName: fullName || "Director",
|
|
|
|
|
companyName: company_name || "the Company",
|
|
|
|
|
signUrl,
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
// 3. Update status to mail_sent
|
2026-08-17 16:33:56 +00:00
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
// ─── Letter HTML → PDF via daemon ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function letterHtml(directorName: string, companyName: string, signUrl: string): string {
|
|
|
|
|
return `<!DOCTYPE html>
|
|
|
|
|
<html><head><meta charset="utf-8"><title>Letter of Appointment as Director</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; }
|
|
|
|
|
.sign-line { margin-top: 28pt; border-top: 1px solid #000; width: 50%; padding-top: 4pt; font-size: 10pt; color: #555; }
|
|
|
|
|
.footer { margin-top: 32pt; font-size: 9pt; color: #666; text-align: center; }
|
|
|
|
|
</style></head>
|
|
|
|
|
<body>
|
|
|
|
|
<h1>LETTER OF APPOINTMENT AS DIRECTOR</h1>
|
|
|
|
|
<p class="meta">Date: ${new Date().toISOString().split("T")[0]}</p>
|
|
|
|
|
|
|
|
|
|
<p><strong>To:</strong> ${directorName}</p>
|
|
|
|
|
|
|
|
|
|
<h2>1. Appointment</h2>
|
|
|
|
|
<p>
|
|
|
|
|
We are pleased to inform you that you have been appointed as a Director of
|
|
|
|
|
<strong>${companyName}</strong> in terms of the company's Memorandum of Incorporation.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<h2>2. Acceptance Required</h2>
|
|
|
|
|
<p>
|
|
|
|
|
Your appointment is subject to your formal acceptance in terms of section 70 of the
|
|
|
|
|
Companies Act 71 of 2008. Until your acceptance is received, your appointment is not
|
|
|
|
|
complete.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<h2>3. How to Accept</h2>
|
|
|
|
|
<p>
|
|
|
|
|
Please accept your appointment and provide your signature by visiting the following
|
|
|
|
|
secure link:
|
|
|
|
|
</p>
|
|
|
|
|
<p style="text-align:center; font-family: monospace; font-size: 10pt; padding: 8pt; background:#f4f4f4; border:1px solid #ddd;">
|
|
|
|
|
${signUrl}
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<p>
|
|
|
|
|
Alternatively, you may print and sign this letter and return the signed copy to the
|
|
|
|
|
company secretary.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div class="sign-line">Director (to sign on acceptance)</div>
|
|
|
|
|
|
|
|
|
|
<p class="footer">
|
|
|
|
|
Issued 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<{ 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 };
|
|
|
|
|
}
|
2026-08-17 16:33:56 +00:00
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
// ─── Email ────────────────────────────────────────────────────────────────
|
2026-08-17 16:33:56 +00:00
|
|
|
|
|
|
|
|
async function sendAppointmentEmail(opts: {
|
|
|
|
|
to: string;
|
|
|
|
|
directorName: string;
|
|
|
|
|
companyName: string;
|
|
|
|
|
signUrl: string;
|
|
|
|
|
}) {
|
2026-08-17 18:57:51 +00:00
|
|
|
const { to, directorName, companyName, signUrl } = opts;
|
2026-08-17 16:33:56 +00:00
|
|
|
|
|
|
|
|
const subject = `[${directorName}] Acceptance letter as director for ${companyName}`;
|
|
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
// 1. Generate PDF via daemon
|
|
|
|
|
const html = letterHtml(directorName, companyName, 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);
|
|
|
|
|
}
|
2026-08-17 16:33:56 +00:00
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
// 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`;
|
|
|
|
|
}
|
2026-08-17 16:33:56 +00:00
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
const emlPath = `/tmp/appt-email-${Date.now()}.eml`;
|
|
|
|
|
writeFileSync(emlPath, eml, "utf8");
|
2026-08-17 16:33:56 +00:00
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
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 {}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 16:33:56 +00:00
|
|
|
|
2026-08-17 20:34:58 +00:00
|
|
|
function emailBodyHtml(directorName: string, companyName: string, signUrl: string): string {
|
|
|
|
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head>
|
2026-08-17 18:57:51 +00:00
|
|
|
<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>
|
2026-08-17 20:34:58 +00:00
|
|
|
<p>Please find attached the letter to accept your appointment as director for <strong>${companyName}</strong>.</p>
|
2026-08-17 18:57:51 +00:00
|
|
|
<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;">
|
|
|
|
|
Accept Appointment & Sign
|
|
|
|
|
</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 acceptance is received, your appointment is not complete.</p>
|
|
|
|
|
<p>Yours sincerely,<br/><strong>The Board of ${companyName}</strong></p>
|
2026-08-17 20:34:58 +00:00
|
|
|
</div></div></body></html>`;
|
|
|
|
|
}
|