2026-08-17 16:33:56 +00:00
|
|
|
// app/api/appointments/route.ts
|
|
|
|
|
// GET — list all director appointments (with expand)
|
|
|
|
|
// POST — create a new appointment, generate letter, email director
|
|
|
|
|
|
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
|
import { fetchCollection, createRecord, updateRecordMultipart } from "@/lib/pb";
|
|
|
|
|
import { writeFileSync, readFileSync, unlinkSync } from "fs";
|
2026-08-17 18:44:45 +00:00
|
|
|
import { execSync, spawn } from "child_process";
|
2026-08-17 16:33:56 +00:00
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
|
|
|
// 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 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Generate a unique sign token
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. Generate letter HTML
|
2026-08-17 16:39:14 +00:00
|
|
|
const letterHtml = buildLetterHtml({ directorName: fullName, companyName: company_name, date: today, signUrl });
|
2026-08-17 16:33:56 +00:00
|
|
|
const htmlPath = `/tmp/appt-letter-${record.id}.html`;
|
|
|
|
|
writeFileSync(htmlPath, letterHtml, "utf8");
|
|
|
|
|
|
|
|
|
|
// 3. Convert to PDF
|
|
|
|
|
const pdfPath = await htmlToPdf(htmlPath);
|
2026-08-17 18:23:02 +00:00
|
|
|
const hasPdf = pdfPath && pdfPath.length > 0;
|
2026-08-17 16:33:56 +00:00
|
|
|
|
2026-08-17 18:23:02 +00:00
|
|
|
// 4. Upload PDF to PB record (only if conversion succeeded)
|
|
|
|
|
if (hasPdf) {
|
|
|
|
|
const pdfBuf = readFileSync(pdfPath);
|
|
|
|
|
const pdfFile = new File([pdfBuf], "appointment-letter.pdf", { type: "application/pdf" });
|
|
|
|
|
await updateRecordMultipart("ysterbaard_director_appointments", record.id, {}, [{ field: "letter", file: pdfFile }]);
|
|
|
|
|
}
|
2026-08-17 16:33:56 +00:00
|
|
|
|
2026-08-17 18:23:02 +00:00
|
|
|
// 5. Send email to director (with or without PDF)
|
2026-08-17 16:33:56 +00:00
|
|
|
await sendAppointmentEmail({
|
|
|
|
|
to: emailTo,
|
|
|
|
|
directorName: fullName || "Director",
|
|
|
|
|
companyName: company_name || "the Company",
|
|
|
|
|
signUrl,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 6. 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 builder ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function buildLetterHtml(opts: {
|
|
|
|
|
directorName: string;
|
|
|
|
|
companyName: string;
|
|
|
|
|
date: string;
|
|
|
|
|
signUrl: string;
|
|
|
|
|
}): string {
|
|
|
|
|
const { directorName, companyName, date, signUrl } = opts;
|
|
|
|
|
return `<!DOCTYPE html>
|
|
|
|
|
<html>
|
|
|
|
|
<head><meta charset="utf-8"><style>
|
|
|
|
|
body { font-family: Arial, sans-serif; font-size: 12pt; margin: 40px 60px; line-height: 1.6; color: #222; }
|
|
|
|
|
.right { text-align: right; }
|
|
|
|
|
h1 { font-size: 14pt; text-align: center; margin-bottom: 4px; font-weight: bold; }
|
|
|
|
|
hr { border: none; border-top: 1px solid #333; margin: 8px 0 16px; }
|
|
|
|
|
p { margin: 8px 0; }
|
|
|
|
|
</style></head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="right">
|
|
|
|
|
<p><strong>${directorName}</strong></p>
|
|
|
|
|
<p>${date}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<h1>LETTER OF APPOINTMENT AS DIRECTOR</h1>
|
|
|
|
|
<hr />
|
|
|
|
|
<p>Dear ${directorName},</p>
|
|
|
|
|
<p>We are pleased to inform you that you have been appointed as a Director of <strong>${companyName}</strong>.</p>
|
|
|
|
|
<p>Your appointment is subject to your formal acceptance in terms of section 70 of the Companies Act 71 of 2008.</p>
|
|
|
|
|
<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="background:#0066cc;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-size:13pt;">
|
|
|
|
|
Accept Appointment & Sign
|
|
|
|
|
</a>
|
|
|
|
|
</p>
|
|
|
|
|
<p>Or copy this link into your browser:<br/><span style="color:#0066cc;font-size:10pt;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>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── PDF conversion ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async function htmlToPdf(htmlPath: string): Promise<string> {
|
2026-08-17 18:47:58 +00:00
|
|
|
const { spawnSync } = await import("child_process");
|
2026-08-17 18:51:55 +00:00
|
|
|
const { existsSync, renameSync } = await import("fs");
|
2026-08-17 16:33:56 +00:00
|
|
|
const pdfPath = htmlPath.replace(".html", ".pdf");
|
2026-08-17 18:47:58 +00:00
|
|
|
const lo = "/Applications/LibreOffice.app/Contents/MacOS/soffice";
|
2026-08-17 18:51:55 +00:00
|
|
|
const env = { ...process.env, HOME: "/Volumes/KIOXIA", PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", TMPDIR: "/tmp" };
|
2026-08-17 16:33:56 +00:00
|
|
|
try {
|
2026-08-17 18:47:58 +00:00
|
|
|
spawnSync(lo, ["--headless", "--convert-to", "pdf", "--outdir", "/tmp/", htmlPath], { timeout: 120, env });
|
2026-08-17 16:33:56 +00:00
|
|
|
} catch {
|
2026-08-17 18:23:02 +00:00
|
|
|
try {
|
2026-08-17 18:47:58 +00:00
|
|
|
spawnSync("soffice", ["--headless", "--convert-to", "pdf", "--outdir", "/tmp/", htmlPath], { timeout: 120, env });
|
2026-08-17 18:23:02 +00:00
|
|
|
} catch {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
2026-08-17 16:33:56 +00:00
|
|
|
}
|
2026-08-17 18:51:55 +00:00
|
|
|
// macOS LibreOffice may write to /private/tmp even when --outdir /tmp/ is given
|
|
|
|
|
if (!existsSync(pdfPath)) {
|
|
|
|
|
const privatePath = pdfPath.replace("/tmp/", "/private/tmp/");
|
|
|
|
|
if (existsSync(privatePath)) {
|
|
|
|
|
renameSync(privatePath, pdfPath);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return existsSync(pdfPath) ? pdfPath : "";
|
2026-08-17 16:33:56 +00:00
|
|
|
}
|
|
|
|
|
// ─── Email ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
const body = `Good day ${directorName}
|
|
|
|
|
|
|
|
|
|
Please find attached the letter to accept your appointment as director for ${companyName}.
|
|
|
|
|
|
|
|
|
|
Please click the link below to accept your appointment and sign:
|
|
|
|
|
|
|
|
|
|
${signUrl}
|
|
|
|
|
|
|
|
|
|
Regards`;
|
|
|
|
|
|
|
|
|
|
const boundary = "----YSTERBAARD" + Date.now();
|
|
|
|
|
|
2026-08-17 18:57:51 +00:00
|
|
|
// Send as HTML email (PDF generation via LibreOffice is unreliable when spawned from Node.js)
|
|
|
|
|
const eml = `To: ${to}\r
|
2026-08-17 16:33:56 +00:00
|
|
|
From: SJ@Dyfins.com\r
|
|
|
|
|
Subject: ${subject}\r
|
|
|
|
|
MIME-Version: 1.0\r
|
2026-08-17 18:57:51 +00:00
|
|
|
Content-Type: text/html; charset="utf-8"\r
|
2026-08-17 16:33:56 +00:00
|
|
|
\r
|
2026-08-17 18:57:51 +00:00
|
|
|
<!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 below the letter to accept your appointment as director for <strong>${companyName}</strong>.</p>
|
|
|
|
|
<div style="background:#fff;border:1px solid #eee;border-radius:6px;padding:20px;margin:20px 0;">
|
|
|
|
|
<h1 style="font-size:16px;text-align:center;margin:0 0 4px;">LETTER OF APPOINTMENT AS DIRECTOR</h1>
|
|
|
|
|
<hr style="border:none;border-top:1px solid #333;margin:8px 0 16px;">
|
|
|
|
|
<p>We are pleased to inform you that you have been appointed as a Director of <strong>${companyName}</strong>.</p>
|
|
|
|
|
<p>Your appointment is subject to your formal acceptance in terms of section 70 of the Companies Act 71 of 2008.</p>
|
|
|
|
|
<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>
|
|
|
|
|
</div></div></body></html>\r
|
2026-08-17 16:33:56 +00:00
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const emlPath = `/tmp/appt-email-${Date.now()}.eml`;
|
|
|
|
|
writeFileSync(emlPath, eml, "utf8");
|
|
|
|
|
|
|
|
|
|
try {
|
2026-08-17 18:31:53 +00:00
|
|
|
// Extract recipients from EML for himalaya smtp send
|
|
|
|
|
const emlContent = readFileSync(emlPath, "utf8");
|
|
|
|
|
const toMatch = emlContent.match(/^To:\s*(.+)$/m);
|
|
|
|
|
const toAddresses = toMatch ? toMatch[1].trim() : "";
|
2026-08-17 18:44:45 +00:00
|
|
|
// Use Node.js stdin to avoid shell /bin/sh ETIMEDOUT
|
|
|
|
|
const himalaya = spawn("/opt/homebrew/bin/himalaya", ["-a", "dyfins", "message", "send", "--save", "Sent"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
|
|
|
const emlBuf = readFileSync(emlPath);
|
|
|
|
|
himalaya.stdin.write(emlBuf);
|
|
|
|
|
himalaya.stdin.end();
|
|
|
|
|
let err = "";
|
|
|
|
|
himalaya.stderr.on("data", (d) => { err += d.toString(); });
|
|
|
|
|
await new Promise<void>((res) => { himalaya.on("close", (code) => { res(); }); });
|
|
|
|
|
if (err.trim()) console.error("himalaya stderr:", err.trim());
|
2026-08-17 16:33:56 +00:00
|
|
|
} finally {
|
|
|
|
|
unlinkSync(emlPath);
|
|
|
|
|
}
|
|
|
|
|
}
|