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.
This commit is contained in:
parent
9bd35898a2
commit
1fecf02214
4 changed files with 749 additions and 195 deletions
|
|
@ -38,13 +38,18 @@ export async function PATCH(
|
|||
|
||||
// Signature upload — multipart
|
||||
if (body.signature_base64) {
|
||||
// Idempotency: if distro already ran, don't resend on retry.
|
||||
// Idempotency: if the distro has already run (or even started), do
|
||||
// not re-send on retry. The status state machine is:
|
||||
// pending -> mail_sent -> signature_received -> mail_sent_to_all
|
||||
// "signature_received" is set as soon as the signature arrives, BEFORE
|
||||
// the email fan-out completes. So we MUST short-circuit on either
|
||||
// signature_received or mail_sent_to_all to prevent double-sends.
|
||||
const existing = await fetchRecord<YsterbaardDirectorAppointment>(
|
||||
"ysterbaard_director_appointments",
|
||||
id
|
||||
);
|
||||
if (existing.status === "mail_sent_to_all") {
|
||||
return NextResponse.json({ ok: true, already: true });
|
||||
if (existing.status === "mail_sent_to_all" || existing.status === "signature_received") {
|
||||
return NextResponse.json({ ok: true, already: true, status: existing.status });
|
||||
}
|
||||
|
||||
// Save signature to PB first (so we don't lose the user's input on distro fail)
|
||||
|
|
@ -88,75 +93,160 @@ export async function PATCH(
|
|||
|
||||
// ─── Signed-letter PDF via daemon ─────────────────────────────────────────
|
||||
|
||||
function signedLetterHtml(directorName: string, companyName: string, date: string, signatureBase64: string): string {
|
||||
// Same master template style as the initial letter (Arial 11, justified, 1.5 spacing,
|
||||
// formal SA Companies Act wording), with the captured signature embedded.
|
||||
// Signed copy of the Consent to Act template. Mirrors the renderer in
|
||||
// app/api/appointments/route.ts — they share the same body. Kept in sync
|
||||
// by hand. The only delta from the initial letter is the embedded signature:
|
||||
// the signUrl / "you may print and sign" copy is suppressed.
|
||||
function signedLetterHtml(
|
||||
director: Record<string, unknown>,
|
||||
company: Record<string, unknown>,
|
||||
date: string,
|
||||
signatureBase64: string
|
||||
): string {
|
||||
const esc = (s: string) => String(s ?? "")
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
const dir = {
|
||||
name: esc([director.first_names, director.surname].filter(Boolean).join(" ")),
|
||||
id: esc(String(director.id_no ?? "")),
|
||||
email: esc(String(director.email ?? "")),
|
||||
cell: esc(String(director.telephone ?? "")),
|
||||
addr1: esc(String(director.residential_address_line_1 ?? "")),
|
||||
addr2: esc(String(director.residential_address_line_2 ?? "")),
|
||||
town: esc(String(director.residential_town_city ?? "")),
|
||||
province: esc(String(director.residential_province ?? "")),
|
||||
zip: esc(String(director.residential_postal_code ?? "")),
|
||||
};
|
||||
const co = {
|
||||
name: esc(String(company.enterprise_name ?? "")),
|
||||
p1: esc(String(company.physical_address_line_1 ?? "")),
|
||||
p2: esc(String(company.physical_address_line_2 ?? "")),
|
||||
pt: esc(String(company.physical_town_city ?? "")),
|
||||
pp: esc(String(company.physical_province ?? "")),
|
||||
pz: esc(String(company.physical_postal_code ?? "")),
|
||||
};
|
||||
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();
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Letter of Appointment as Director</title>
|
||||
<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.5; text-align: justify; margin: 0; }
|
||||
h1 { font-family: Arial, sans-serif; font-size: 13pt; font-weight: bold; color: #0a1a3a; text-align: center;
|
||||
margin: 0 0 6pt; padding: 0 0 6pt; border-bottom: 2px solid #0a1a3a; }
|
||||
.right { text-align: right; }
|
||||
.left { text-align: left; }
|
||||
.director-block { margin-top: 16pt; margin-bottom: 12pt; }
|
||||
.director-name { font-weight: bold; color: #0a1a3a; font-size: 11pt; }
|
||||
.director-line { color: #222; font-size: 10pt; }
|
||||
.addressee-block { margin: 12pt 0 12pt 0; }
|
||||
.addressee-name { font-weight: bold; }
|
||||
.salutation { margin: 8pt 0 12pt 0; }
|
||||
p { margin: 6pt 0; }
|
||||
.signature-block { margin-top: 32pt; }
|
||||
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; }
|
||||
.top-left { text-align: left; }
|
||||
.addressee-block { margin: 12pt 0 16pt 0; }
|
||||
.addressee-name { font-weight: bold; margin-top: 12pt; }
|
||||
.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; }
|
||||
.sig-img { height: 60pt; display: block; margin: 8pt 0; }
|
||||
.sig-name { font-weight: bold; font-size: 11pt; margin-top: 4pt; }
|
||||
.sig-role { color: #444; font-size: 10pt; }
|
||||
.footer { margin-top: 24pt; font-size: 9pt; color: #555; text-align: center; font-style: italic; }
|
||||
p { margin: 6pt 0; }
|
||||
</style></head>
|
||||
<body>
|
||||
|
||||
<h1>LETTER OF APPOINTMENT AS DIRECTOR</h1>
|
||||
|
||||
<div class="right" style="margin-top: 14pt;">
|
||||
<span class="director-line">${date}</span>
|
||||
<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="director-block right">
|
||||
<div class="director-name">${directorName}</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="addressee-block left">
|
||||
<div class="addressee-name">The Board of Directors and Company Secretary</div>
|
||||
<div>${companyName}</div>
|
||||
</div>
|
||||
<div class="hdr">Dear Members of The Board</div>
|
||||
<div class="re">RE: CONSENT TO ACT AS DIRECTOR</div>
|
||||
|
||||
<div class="salutation">Dear Members of the Board</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>
|
||||
|
||||
<img class="sig-img" src="data:image/png;base64,${signatureBase64}" alt="Signature"/>
|
||||
|
||||
<p>
|
||||
We are pleased to confirm that <strong>${directorName}</strong> has accepted the appointment as a
|
||||
Director of <strong>${companyName}</strong> in terms of the company's Memorandum of Incorporation.
|
||||
<strong>${dir.name}</strong><br/>
|
||||
ID ${dir.id}
|
||||
</p>
|
||||
|
||||
<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 (the “Act”).
|
||||
</p>
|
||||
|
||||
<p>
|
||||
The Director's acceptance is evidenced by the signature reproduced below. A signed copy of this
|
||||
letter is filed with the Company Secretary.
|
||||
</p>
|
||||
|
||||
<p>Yours faithfully,</p>
|
||||
|
||||
<div class="signature-block">
|
||||
<img class="sig-img" src="data:image/png;base64,${signatureBase64}" alt="Signature"/>
|
||||
<div class="sig-name">${directorName}</div>
|
||||
<div class="sig-role">Director — ${companyName}</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Filed in terms of the Companies Act 71 of 2008
|
||||
Filed in terms of section 66(7)(b) of the Companies Act 71 of 2008
|
||||
</div>
|
||||
|
||||
</body></html>`;
|
||||
|
|
@ -216,7 +306,7 @@ async function sendSignedDistro(opts: { id: string; signatureBase64: string }) {
|
|||
let pdfFilename = `signed-acceptance-${Date.now()}.pdf`;
|
||||
try {
|
||||
pdfBytes = await renderPdf(
|
||||
signedLetterHtml(directorName, companyName, date, opts.signatureBase64),
|
||||
signedLetterHtml(director ?? {}, company ?? {}, date, opts.signatureBase64),
|
||||
`signed-acceptance-${companyName.replace(/[^A-Za-z0-9]/g, "_")}`
|
||||
);
|
||||
} catch (e) {
|
||||
|
|
@ -246,7 +336,7 @@ async function sendSignedEmail(opts: {
|
|||
pdfFilename: string;
|
||||
}) {
|
||||
const { to, directorName, companyName, date, signatureBase64, pdfBytes, pdfFilename } = opts;
|
||||
const subject = `${directorName} accepted the appointment as director of ${companyName}`;
|
||||
const subject = `${directorName} signed consent to act as director of ${companyName}`;
|
||||
|
||||
const boundary = "----YSTERBAARD_" + Date.now();
|
||||
let eml = `To: ${to}\r\n`;
|
||||
|
|
@ -297,14 +387,14 @@ function signedEmailHtml(directorName: string, companyName: string, date: 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>
|
||||
<h2 style="text-align:center;color:#0a0a0a;font-size:18px;margin:0 0 16px;">Consent to Act as Director — Signed</h2>
|
||||
<p>I am glad to inform you that <strong>${directorName}</strong> has signed the Consent to Act 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>
|
||||
<h3 style="font-size:14px;text-align:center;margin:0 0 12px;">CONSENT TO ACT 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>
|
||||
<p><strong>${directorName}</strong> hereby consents to act as Director of <strong>${companyName}</strong> in accordance with the provisions of Section 66(7)(b) of the Companies Act 71 of 2008.</p>
|
||||
<p>The consent was signed on ${date} and is filed in terms of the Act.</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>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
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";
|
||||
|
||||
|
|
@ -29,6 +30,10 @@ export async function GET() {
|
|||
// 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,
|
||||
|
|
@ -38,6 +43,7 @@ export async function POST(req: NextRequest) {
|
|||
director_name,
|
||||
director_surname,
|
||||
company_name,
|
||||
proceed_anyway = false,
|
||||
} = body;
|
||||
|
||||
if (!director_id || !company_id) {
|
||||
|
|
@ -47,11 +53,100 @@ export async function POST(req: NextRequest) {
|
|||
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
|
||||
// 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,
|
||||
|
|
@ -59,14 +154,23 @@ export async function POST(req: NextRequest) {
|
|||
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 || "Director",
|
||||
companyName: company_name || "the Company",
|
||||
directorName: fullName,
|
||||
companyName,
|
||||
signUrl,
|
||||
directorRecord: fullDirector,
|
||||
companyRecord: fullCompany,
|
||||
});
|
||||
|
||||
// 3. Update status to mail_sent
|
||||
|
|
@ -82,108 +186,194 @@ export async function POST(req: NextRequest) {
|
|||
|
||||
// ─── 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 { directorName, companyName, signUrl, signatureBase64, letterDate } = opts;
|
||||
const o = opts;
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const date = letterDate || today;
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
// Master template style (per SJ 2026-07-22, matches the resignation letter):
|
||||
// - No logo / no letterhead (formal SA legal style)
|
||||
// - Arial 11pt body, justified, 1.5 line spacing
|
||||
// - "LETTER OF APPOINTMENT AS DIRECTOR" header (Arial 13pt bold, navy bottom border)
|
||||
// - Director details block top-right
|
||||
// - Addressee block left
|
||||
// - Salutation: "Dear <Director full name>"
|
||||
// - Body references Companies Act 71 of 2008
|
||||
// - Signature block at bottom with printed name + ID Number
|
||||
return `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Letter of Appointment as Director</title>
|
||||
<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.5; text-align: justify; margin: 0; }
|
||||
h1 { font-family: Arial, sans-serif; font-size: 13pt; font-weight: bold; color: #0a1a3a; text-align: center;
|
||||
margin: 0 0 6pt; padding: 0 0 6pt; border-bottom: 2px solid #0a1a3a; }
|
||||
.right { text-align: right; }
|
||||
.left { text-align: left; }
|
||||
.director-block { margin-top: 16pt; margin-bottom: 12pt; }
|
||||
.director-name { font-weight: bold; color: #0a1a3a; font-size: 11pt; }
|
||||
.director-line { color: #222; font-size: 10pt; }
|
||||
.addressee-block { margin: 12pt 0 12pt 0; }
|
||||
.addressee-name { font-weight: bold; }
|
||||
.salutation { margin: 8pt 0 12pt 0; }
|
||||
p { margin: 6pt 0; }
|
||||
.signature-block { margin-top: 32pt; }
|
||||
.sig-line { border-top: 1px solid #000; padding-top: 4pt; width: 60%; margin-top: 24pt; }
|
||||
.sig-name { font-weight: bold; font-size: 11pt; margin-top: 4pt; }
|
||||
.sig-role { color: #444; font-size: 10pt; }
|
||||
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; }
|
||||
.footer { margin-top: 24pt; font-size: 9pt; color: #555; text-align: center; font-style: italic; }
|
||||
.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>
|
||||
|
||||
<h1>LETTER OF APPOINTMENT AS DIRECTOR</h1>
|
||||
|
||||
<div class="right" style="margin-top: 14pt;">
|
||||
<span class="director-line">${date}</span>
|
||||
<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="director-block right">
|
||||
<div class="director-name">${directorName}</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="addressee-block left">
|
||||
<div class="addressee-name">The Board of Directors</div>
|
||||
<div>${companyName}</div>
|
||||
</div>
|
||||
<div class="hdr">Dear Members of The Board</div>
|
||||
<div class="re">RE: CONSENT TO ACT AS DIRECTOR</div>
|
||||
|
||||
<div class="salutation">Dear ${directorName}</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"> </div>`}
|
||||
|
||||
<p>
|
||||
We are pleased to confirm that you have been appointed as a Director of <strong>${companyName}</strong>
|
||||
in terms of the company’s Memorandum of Incorporation. Your appointment takes effect from the date of
|
||||
this letter and is subject to your formal acceptance in terms of section 70 of the Companies Act 71 of 2008
|
||||
(the “Act”).
|
||||
<strong>${dir.name}</strong><br/>
|
||||
ID ${dir.id}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Until your acceptance is received, your appointment is not complete. We kindly request that you signify
|
||||
your acceptance by signing and returning a copy of this letter, or by accepting electronically via the
|
||||
secure link provided below.
|
||||
</p>
|
||||
${o.signatureBase64 ? `` : `
|
||||
<div class="sign-link-box"><strong>To accept electronically, visit:</strong><br/>${o.signUrl}</div>
|
||||
|
||||
<p>To accept your appointment electronically, please visit the following secure link:</p>
|
||||
|
||||
<div class="sign-link-box">${signUrl}</div>
|
||||
|
||||
<p>
|
||||
Alternatively, you may print this letter, sign in the space provided below, and return the signed copy
|
||||
to the Company Secretary at the company’s registered address.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Your duties and responsibilities as a Director are set out in the Act, the company’s Memorandum of
|
||||
Incorporation, and any applicable board charter. We look forward to your valued contribution to the
|
||||
governance and growth of the company.
|
||||
</p>
|
||||
|
||||
<p>Yours faithfully,</p>
|
||||
|
||||
<div class="signature-block">
|
||||
${signatureBase64 ? `<img class="sig-img" src="data:image/png;base64,${signatureBase64}" alt="Signature"/>` : `<div class="sig-line"> </div>`}
|
||||
<div class="sig-name">${directorName}</div>
|
||||
<div class="sig-role">Director</div>
|
||||
</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 the Companies Act 71 of 2008
|
||||
Issued in terms of section 66(7)(b) of the Companies Act 71 of 2008
|
||||
</div>
|
||||
|
||||
</body></html>`;
|
||||
|
|
@ -210,13 +400,32 @@ async function sendAppointmentEmail(opts: {
|
|||
directorName: string;
|
||||
companyName: string;
|
||||
signUrl: string;
|
||||
directorRecord: Record<string, unknown>;
|
||||
companyRecord: Record<string, unknown>;
|
||||
}) {
|
||||
const { to, directorName, companyName, signUrl } = opts;
|
||||
const { to, directorName, companyName, signUrl, directorRecord, companyRecord } = opts;
|
||||
|
||||
const subject = `[${directorName}] Acceptance letter as director for ${companyName}`;
|
||||
const subject = `Consent to act as director — ${companyName}`;
|
||||
|
||||
// 1. Generate PDF via daemon
|
||||
const html = letterHtml({ directorName, companyName, signUrl });
|
||||
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 {
|
||||
|
|
@ -286,16 +495,17 @@ function emailBodyHtml(directorName: string, companyName: string, signUrl: strin
|
|||
<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 letter to accept your appointment as director for <strong>${companyName}</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;">
|
||||
Accept Appointment & Sign
|
||||
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 acceptance is received, your appointment is not complete.</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>`;
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
"use client";
|
||||
|
||||
// components/DirectorAppointmentTab.tsx
|
||||
// Table of all director appointments + "Acceptance Letter" modal
|
||||
|
||||
"use client";
|
||||
// with a pre-send validation gate (per SJ 2026-08-18): the modal shows
|
||||
// the resolved recipient list (new director + linked directors + company
|
||||
// email) and flags any missing/invalid emails BEFORE sending. The user
|
||||
// can Cancel, Edit (jump to the edit screen), or Proceed anyway.
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import type { YsterbaardDirectorAppointment } from "@/lib/types";
|
||||
|
|
@ -30,6 +34,21 @@ interface Appointment extends YsterbaardDirectorAppointment {
|
|||
_company_name?: string;
|
||||
}
|
||||
|
||||
type RecipientStatus = "valid" | "missing" | "invalid";
|
||||
interface Recipient {
|
||||
key: string;
|
||||
label: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: RecipientStatus;
|
||||
}
|
||||
interface ValidationReport {
|
||||
preview: boolean;
|
||||
initial: Recipient[];
|
||||
distro: Recipient[];
|
||||
problems: number;
|
||||
}
|
||||
|
||||
function StatusBadge({ variant, children }: { variant: string; children: React.ReactNode }) {
|
||||
const classes: Record<string, string> = {
|
||||
muted: "bg-slate-100 text-slate-600",
|
||||
|
|
@ -44,6 +63,22 @@ function StatusBadge({ variant, children }: { variant: string; children: React.R
|
|||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: RecipientStatus }) {
|
||||
if (status === "valid") {
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 text-green-700 text-xs font-bold flex-shrink-0" title="Valid email">✓</span>
|
||||
);
|
||||
}
|
||||
if (status === "missing") {
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-amber-100 text-amber-700 text-xs font-bold flex-shrink-0" title="Missing email">!</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-red-100 text-red-700 text-xs font-bold flex-shrink-0" title="Invalid format">x</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DirectorAppointmentTab() {
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -57,6 +92,10 @@ export default function DirectorAppointmentTab() {
|
|||
const [selectedDirector, setSelectedDirector] = useState("");
|
||||
const [selectedCompany, setSelectedCompany] = useState("");
|
||||
|
||||
// Validation gate
|
||||
const [report, setReport] = useState<ValidationReport | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
|
||||
const loadAppointments = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/appointments");
|
||||
|
|
@ -86,6 +125,7 @@ export default function DirectorAppointmentTab() {
|
|||
setSendError(null);
|
||||
setSelectedDirector("");
|
||||
setSelectedCompany("");
|
||||
setReport(null);
|
||||
|
||||
// Load directors
|
||||
try {
|
||||
|
|
@ -118,12 +158,102 @@ export default function DirectorAppointmentTab() {
|
|||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
// Fetch the full company record so we can show the company email in the
|
||||
// validation panel.
|
||||
const fetchCompanyDetails = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/pb/records/get?collection=ysterbaard_companies&id=${id}&fields=id,enterprise_name,company_email`);
|
||||
if (res.ok) return res.json();
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
};
|
||||
const fetchDirectorDetails = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/pb/records/get?collection=ysterbaard_directors&id=${id}&fields=id,first_names,surname,email`);
|
||||
if (res.ok) return res.json();
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!selectedDirector || !selectedCompany) {
|
||||
setSendError("Please select a company and a director first.");
|
||||
return;
|
||||
}
|
||||
setPreviewing(true);
|
||||
setSendError(null);
|
||||
setReport(null);
|
||||
|
||||
try {
|
||||
// Build the local validation report — same shape the server would
|
||||
// return, but computed client-side so we don't round-trip to the
|
||||
// server just to render the gate. The server re-validates on POST.
|
||||
const director = directors.find((d) => d.id === selectedDirector);
|
||||
const company = companies.find((c) => c.id === selectedCompany);
|
||||
|
||||
const dirDetails = await fetchDirectorDetails(selectedDirector);
|
||||
const coDetails = await fetchCompanyDetails(selectedCompany);
|
||||
|
||||
const emailRx = /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/;
|
||||
const classify = (e: string | undefined | null): RecipientStatus => {
|
||||
const v = (e ?? "").trim();
|
||||
if (!v) return "missing";
|
||||
return emailRx.test(v) ? "valid" : "invalid";
|
||||
};
|
||||
|
||||
// Fetch linked directors for the company
|
||||
let linkedDirectors: Array<{ id: string; first_names?: string; surname?: string; email?: string }> = [];
|
||||
try {
|
||||
const res = await fetch("/api/pb/records/list?collection=ysterbaard_company_directors&perPage=200&filter=" + encodeURIComponent(`company = '${selectedCompany}'`) + "&expand=director&fields=expand.director.id,expand.director.first_names,expand.director.surname,expand.director.email");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
linkedDirectors = (data.items || [])
|
||||
.map((j: { expand?: { director?: { id: string; first_names?: string; surname?: string; email?: string } } }) => j.expand?.director)
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch { /* ignore — junction may not exist */ }
|
||||
|
||||
const initial: Recipient[] = [{
|
||||
key: "new_director",
|
||||
label: `New director: ${director?.label ?? "?"}`,
|
||||
email: dirDetails?.email ?? director?.email ?? "",
|
||||
role: "to",
|
||||
status: classify(dirDetails?.email ?? director?.email),
|
||||
}];
|
||||
|
||||
const distro: Recipient[] = [
|
||||
...linkedDirectors.map((d) => ({
|
||||
key: `director:${d.id}`,
|
||||
label: `Director: ${[d.first_names, d.surname].filter(Boolean).join(" ") || d.id}`,
|
||||
email: d.email ?? "",
|
||||
role: "to",
|
||||
status: classify(d.email),
|
||||
})),
|
||||
{
|
||||
key: "company_email",
|
||||
label: `Company: ${coDetails?.enterprise_name ?? company?.label ?? "?"}`,
|
||||
email: coDetails?.company_email ?? "",
|
||||
role: "to",
|
||||
status: classify(coDetails?.company_email),
|
||||
},
|
||||
];
|
||||
|
||||
const all = [...initial, ...distro];
|
||||
const problems = all.filter((r) => r.status !== "valid").length;
|
||||
|
||||
setReport({ preview: true, initial, distro, problems });
|
||||
} catch (err: unknown) {
|
||||
setSendError(err instanceof Error ? err.message : "Validation failed.");
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (proceedAnyway: boolean) => {
|
||||
if (!selectedDirector || !selectedCompany) return;
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
|
||||
// Get full director + company details
|
||||
const director = directors.find((d) => d.id === selectedDirector);
|
||||
const company = companies.find((c) => c.id === selectedCompany);
|
||||
|
||||
|
|
@ -139,15 +269,27 @@ export default function DirectorAppointmentTab() {
|
|||
director_name: director?.label.split(" ")[0] || "",
|
||||
director_surname: director?.label.split(" ").slice(1).join(" ") || "",
|
||||
company_name: company?.label || "",
|
||||
proceed_anyway: proceedAnyway,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
// 422 is the validation failure — surface the report so the user can fix
|
||||
if (res.status === 422 && data.initial && data.distro) {
|
||||
setReport({
|
||||
preview: false,
|
||||
initial: data.initial,
|
||||
distro: data.distro,
|
||||
problems: (data.initial.length + data.distro.length),
|
||||
});
|
||||
throw new Error(data.error || "Server-side validation failed.");
|
||||
}
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
setReport(null);
|
||||
loadAppointments();
|
||||
} catch (err: unknown) {
|
||||
setSendError(err instanceof Error ? err.message : "Failed to send letter");
|
||||
|
|
@ -176,7 +318,7 @@ export default function DirectorAppointmentTab() {
|
|||
<div className="text-center py-12 text-muted text-sm">Loading...</div>
|
||||
) : appointments.length === 0 ? (
|
||||
<div className="bg-surface border border-border rounded-lg p-12 text-center text-muted text-sm">
|
||||
No director appointments yet. Click “Acceptance Letter” to send the first one.
|
||||
No director consents yet. Click “Acceptance Letter” to send the first one.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
||||
|
|
@ -215,44 +357,105 @@ export default function DirectorAppointmentTab() {
|
|||
{showModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowModal(false); }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) { setShowModal(false); setReport(null); } }}
|
||||
>
|
||||
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<h2 className="text-lg font-semibold text-fg">Send Acceptance Letter</h2>
|
||||
<p className="text-sm text-muted mt-0.5">Select a company and director to initiate the appointment.</p>
|
||||
<div className="bg-surface border border-border rounded-xl shadow-2xl w-full max-w-md flex flex-col max-h-[90vh]">
|
||||
<div className="border-b border-border px-6 py-4 flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-fg">Send Consent to Act as Director</h2>
|
||||
<p className="text-sm text-muted mt-0.5">
|
||||
{report ? "Review the recipient list before sending." : "Select a company and director to send the consent letter for signature."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Company */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-fg mb-1.5">Company</label>
|
||||
<select
|
||||
value={selectedCompany}
|
||||
onChange={(e) => setSelectedCompany(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">— Select company —</option>
|
||||
{companies.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||
{/* Always show the picker so the user can change selection */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-fg mb-1.5">Company</label>
|
||||
<select
|
||||
value={selectedCompany}
|
||||
onChange={(e) => { setSelectedCompany(e.target.value); setReport(null); }}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">— Select company —</option>
|
||||
{companies.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-fg mb-1.5">Director</label>
|
||||
<select
|
||||
value={selectedDirector}
|
||||
onChange={(e) => { setSelectedDirector(e.target.value); setReport(null); }}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">— Select director —</option>
|
||||
{directors.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Director */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-fg mb-1.5">Director</label>
|
||||
<select
|
||||
value={selectedDirector}
|
||||
onChange={(e) => setSelectedDirector(e.target.value)}
|
||||
className="w-full bg-surface border border-border rounded px-3 py-2.5 text-sm text-fg focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">— Select director —</option>
|
||||
{directors.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Validation panel */}
|
||||
{report && (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className={`px-4 py-2.5 text-sm font-semibold flex items-center justify-between ${
|
||||
report.problems === 0
|
||||
? "bg-green-50 text-green-800 border-b border-green-200"
|
||||
: "bg-amber-50 text-amber-900 border-b border-amber-200"
|
||||
}`}>
|
||||
<span>
|
||||
{report.problems === 0
|
||||
? "All recipients look good"
|
||||
: `${report.problems} recipient${report.problems !== 1 ? "s" : ""} need attention`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1.5">Initial send</p>
|
||||
<div className="space-y-1.5">
|
||||
{report.initial.map((r) => (
|
||||
<div key={r.key} className="flex items-center gap-2">
|
||||
<StatusDot status={r.status} />
|
||||
<span className="text-fg-2 text-xs flex-1">{r.label}</span>
|
||||
<span className={r.status === "valid" ? "text-fg-2 text-xs font-mono" : "text-amber-800 text-xs font-mono"}>
|
||||
{r.status === "missing" ? "(missing)" : r.status === "invalid" ? r.email : r.email}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-1.5">
|
||||
After director signs (sent to all)
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{report.distro.map((r) => (
|
||||
<div key={r.key} className="flex items-center gap-2">
|
||||
<StatusDot status={r.status} />
|
||||
<span className="text-fg-2 text-xs flex-1">{r.label}</span>
|
||||
<span className={r.status === "valid" ? "text-fg-2 text-xs font-mono" : "text-amber-800 text-xs font-mono"}>
|
||||
{r.status === "missing" ? "(missing)" : r.status === "invalid" ? r.email : r.email}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.problems > 0 && (
|
||||
<p className="text-xs text-amber-900 bg-amber-100 border border-amber-200 rounded px-3 py-2">
|
||||
<strong>Heads up:</strong> recipients flagged above will be skipped when the signed letter is distributed.
|
||||
You can fix the records first, or proceed anyway and email them manually.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sendError && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-sm text-red-400">
|
||||
|
|
@ -261,20 +464,54 @@ export default function DirectorAppointmentTab() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-6 py-4 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!selectedDirector || !selectedCompany || sending}
|
||||
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{sending ? "Sending..." : "Send Letter"}
|
||||
</button>
|
||||
<div className="border-t border-border px-6 py-4 flex justify-between gap-3 flex-shrink-0">
|
||||
{/* Left side: Cancel / Edit records */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setReport(null); }}
|
||||
className="px-4 py-2 text-sm font-semibold text-muted hover:text-fg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{report && report.problems > 0 && (
|
||||
<a
|
||||
href={selectedCompany ? `/companies/${selectedCompany}` : "/companies"}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="px-4 py-2 text-sm font-semibold text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
Fix records first →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Validate / Send / Proceed anyway */}
|
||||
{!report ? (
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={!selectedDirector || !selectedCompany || previewing}
|
||||
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{previewing ? "Validating..." : "Validate recipients"}
|
||||
</button>
|
||||
) : report.problems === 0 ? (
|
||||
<button
|
||||
onClick={() => handleSend(false)}
|
||||
disabled={sending}
|
||||
className="px-4 py-2 bg-accent text-accent-on rounded font-semibold text-sm disabled:opacity-40 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{sending ? "Sending..." : "Send letter"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleSend(true)}
|
||||
disabled={sending}
|
||||
className="px-4 py-2 bg-amber-600 text-white rounded font-semibold text-sm disabled:opacity-40 hover:bg-amber-700 transition-colors"
|
||||
title="Send anyway — the flagged recipients will be skipped when the signed letter is distributed"
|
||||
>
|
||||
{sending ? "Sending..." : "Proceed anyway"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,3 +60,20 @@ export function deriveCompanyStatus(
|
|||
export function quoteFilter(input: string): string {
|
||||
return input.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
|
||||
/** Loose email format check. Returns true if the string looks like
|
||||
* a real address — not a typo, not empty, not missing the domain. */
|
||||
export function isValidEmail(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
const v = value.trim();
|
||||
if (!v) return false;
|
||||
// Standard-ish: local@domain.tld, no spaces, must contain @ and a dot in the domain
|
||||
// Allow + (plus addressing), dots in local part, etc.
|
||||
return /^[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$/.test(v);
|
||||
}
|
||||
|
||||
/** True if the recipient is missing OR has an invalid format. */
|
||||
export function isMissingOrInvalidEmail(value: unknown): boolean {
|
||||
return !isValidEmail(value);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue