WORKFLOW-APP/app/api/appointments/route.ts
Dave 2fb979b98a Director appointment acceptance — full feature
- DirectorAppointmentTab: table + 'Acceptance Letter' modal with company/director dropdowns
- GET/POST /api/appointments: create record, generate HTML letter, convert to PDF via LibreOffice, email to director via himalaya SMTP
- PATCH /api/appointments/[id]: save signature PNG, embed in signed letter PDF, mail to all company directors
- GET /api/appointments/sign/[token]: public token lookup
- /sign/[token]/page.tsx: public signature canvas (mouse + touch)
- ysterbaard_director_appointments PB collection (requires manual schema setup)
- Section 3 'Director Appointment' now active in ResolutionExplorer
- NEXT_PUBLIC_APP_URL added to .env.local
2026-08-17 18:39:14 +02:00

211 lines
7.4 KiB
TypeScript

// 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";
import { execSync } from "child_process";
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
const letterHtml = buildLetterHtml({ directorName: fullName, companyName: company_name, date: today, signUrl });
const htmlPath = `/tmp/appt-letter-${record.id}.html`;
writeFileSync(htmlPath, letterHtml, "utf8");
// 3. Convert to PDF
const pdfPath = await htmlToPdf(htmlPath);
// 4. Upload PDF to PB record
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 }]);
// 5. Send email to director
await sendAppointmentEmail({
to: emailTo,
directorName: fullName || "Director",
companyName: company_name || "the Company",
signUrl,
pdfPath,
});
// 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 &amp; 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> {
const { execSync } = await import("child_process");
const pdfPath = htmlPath.replace(".html", ".pdf");
try {
execSync(
"/Applications/LibreOffice.app/Contents/MacOS/soffice --headless --convert-to pdf --outdir /tmp/ " + JSON.stringify(htmlPath),
{ timeout: 30 }
);
} catch {
// try soffice directly
execSync(
`soffice --headless --convert-to pdf --outdir /tmp/ "${htmlPath}"`,
{ timeout: 30 }
);
}
return pdfPath;
}
// ─── Email ───────────────────────────────────────────────────────────────
async function sendAppointmentEmail(opts: {
to: string;
directorName: string;
companyName: string;
signUrl: string;
pdfPath: string;
}) {
const { to, directorName, companyName, signUrl } = opts;
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();
const pdfBuf = readFileSync(opts.pdfPath);
const pdfB64 = pdfBuf.toString("base64");
const eml = `To: ${to}\r
From: SJ@Dyfins.com\r
Subject: ${subject}\r
MIME-Version: 1.0\r
Content-Type: multipart/mixed; boundary="${boundary}"\r
\r
--${boundary}\r
Content-Type: text/plain; charset="utf-8"\r
\r
${body}\r
--${boundary}\r
Content-Type: application/pdf; name="appointment-letter.pdf"\r
Content-Transfer-Encoding: base64\r
Content-Disposition: attachment; filename="appointment-letter.pdf"\r
\r
${pdfB64}\r
--${boundary}--\r
`;
const emlPath = `/tmp/appt-email-${Date.now()}.eml`;
writeFileSync(emlPath, eml, "utf8");
try {
execSync(`himalaya -a dyfins --backend smtp email send < "${emlPath}"`, { timeout: 30 });
} finally {
unlinkSync(emlPath);
try { unlinkSync(opts.pdfPath); } catch { /* ok */ }
}
}