Adds the full Next.js app (app/, components/, lib/, scripts/, assets/, docs/, Dockerfile, compose, configs) to version control. Security: - CIPC BizPortal customer-code logins (Login 1 + Login 3) now read CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE / CIPC_LOGIN3_PASSWORD from .env.local instead of source. - BizPortal personal login in apply-bizportal route reads BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD. - Added .env.example as the committed template. .gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright storage_state.json (live CIPC cookies), scratch dirs, client PDFs and debug screenshots.
637 lines
32 KiB
TypeScript
637 lines
32 KiB
TypeScript
// app/api/appointments/resend/route.ts
|
|
// POST /api/appointments/resend
|
|
//
|
|
// Re-sends an existing appointment's email using the SAME template the
|
|
// original send uses. mail=initial or mail=signed.
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { fetchRecord, updateRecordMultipart } from "@/lib/pb";
|
|
import type { YsterbaardDirectorAppointment } from "@/lib/types";
|
|
import { spawn } from "child_process";
|
|
import { HIMALAYA_BIN, himalayaSendArgs } from "@/lib/himalaya";
|
|
|
|
const PDFD_URL = process.env.PDFD_URL || "http://127.0.0.1:3070";
|
|
const PB_URL = "https://pb.agent.apphub.co.za";
|
|
const PB_TOKEN = process.env.PB_TOKEN || "";
|
|
|
|
interface AppointmentExpand {
|
|
director?: {
|
|
first_names?: string; surname?: string; email?: string; telephone?: string;
|
|
id_no?: string;
|
|
residential_address_line_1?: string; residential_address_line_2?: string;
|
|
residential_town_city?: string; residential_province?: string;
|
|
residential_postal_code?: string; residential_address?: string;
|
|
};
|
|
company?: {
|
|
enterprise_name?: string;
|
|
physical_address_line_1?: string; physical_address_line_2?: string;
|
|
physical_town_city?: string; physical_province?: string; physical_postal_code?: string;
|
|
registered_address_line_1?: string; registered_address_line_2?: string;
|
|
registered_town_city?: string; registered_province?: string; registered_postal_code?: string;
|
|
physical_address?: string;
|
|
};
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
try {
|
|
const body = await req.json();
|
|
const { id, mail } = body as { id?: string; mail?: "initial" | "signed" };
|
|
|
|
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
|
if (mail !== "initial" && mail !== "signed") {
|
|
return NextResponse.json({ error: "mail must be 'initial' or 'signed'" }, { status: 400 });
|
|
}
|
|
|
|
const appt = await fetchRecord<YsterbaardDirectorAppointment>(
|
|
"ysterbaard_director_appointments",
|
|
id,
|
|
"director,company"
|
|
);
|
|
const director = (appt.expand as AppointmentExpand | undefined)?.director ?? {};
|
|
const company = (appt.expand as AppointmentExpand | undefined)?.company ?? {};
|
|
|
|
const directorName = [director.first_names, director.surname].filter(Boolean).join(" ") || "Director";
|
|
const companyName = String(company.enterprise_name ?? "the Company");
|
|
const signUrl = `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:6001"}/sign/${appt.sign_token}`;
|
|
const to = appt.sent_to || "sj@dyfins.com";
|
|
|
|
// Pick real addresses — prefer structured fields unless they look like test data.
|
|
const dirAddr = pickAddress(
|
|
{
|
|
line_1: director.residential_address_line_1,
|
|
line_2: director.residential_address_line_2,
|
|
town_city: director.residential_town_city,
|
|
province: director.residential_province,
|
|
postal_code: director.residential_postal_code,
|
|
},
|
|
director.residential_address as string | undefined
|
|
);
|
|
const coAddr = pickAddress(
|
|
{
|
|
line_1: company.physical_address_line_1,
|
|
line_2: company.physical_address_line_2,
|
|
town_city: company.physical_town_city,
|
|
province: company.physical_province,
|
|
postal_code: company.physical_postal_code,
|
|
},
|
|
company.physical_address as string | undefined
|
|
);
|
|
|
|
let eml: string;
|
|
|
|
if (mail === "initial") {
|
|
const html = letterHtml({
|
|
directorName,
|
|
directorIdNo: String(director.id_no ?? ""),
|
|
directorEmail: String(director.email ?? ""),
|
|
directorCellPhone: String(director.telephone ?? ""),
|
|
residentialAddressLine1: dirAddr.line_1,
|
|
residentialAddressLine2: dirAddr.line_2,
|
|
residentialTownCity: dirAddr.town_city,
|
|
residentialProvince: dirAddr.province ?? "",
|
|
residentialPostalCode: dirAddr.postal_code ?? "",
|
|
companyName,
|
|
physicalAddressLine1: coAddr.line_1,
|
|
physicalAddressLine2: coAddr.line_2,
|
|
physicalTownCity: coAddr.town_city,
|
|
physicalProvince: coAddr.province ?? "",
|
|
physicalPostalCode: coAddr.postal_code ?? "",
|
|
signUrl,
|
|
});
|
|
const filename = `acceptance-letter-${companyName.replace(/[^A-Za-z0-9]/g, "_")}`;
|
|
const pdfBytes = await renderPdf(html, filename);
|
|
eml = buildInitialEml({ to, directorName, companyName, signUrl, pdfBytes, pdfFilename: `${filename}.pdf` });
|
|
|
|
// Persist to PB (only when the renderer produced a PDF)
|
|
if (pdfBytes) {
|
|
try {
|
|
const pdfFile = new File([pdfBytes], `${filename}.pdf`, { type: "application/pdf" });
|
|
await updateRecordMultipart(
|
|
"ysterbaard_director_appointments",
|
|
appt.id,
|
|
{},
|
|
[{ field: "letter", file: pdfFile }]
|
|
);
|
|
} catch (saveErr) {
|
|
console.error("Resend: failed to persist initial PDF to PB:", saveErr);
|
|
}
|
|
}
|
|
} else {
|
|
const signatureBase64 = await fetchSignatureBase64(appt);
|
|
if (!signatureBase64) {
|
|
return NextResponse.json({ error: "No signature stored for this appointment — cannot resend signed copy." }, { status: 400 });
|
|
}
|
|
const date = new Date().toISOString().split("T")[0];
|
|
const directorForRender = {
|
|
...director,
|
|
residential_address_line_1: dirAddr.line_1,
|
|
residential_address_line_2: dirAddr.line_2,
|
|
residential_town_city: dirAddr.town_city,
|
|
residential_province: dirAddr.province,
|
|
residential_postal_code: dirAddr.postal_code,
|
|
};
|
|
const companyForRender = {
|
|
...company,
|
|
physical_address_line_1: coAddr.line_1,
|
|
physical_address_line_2: coAddr.line_2,
|
|
physical_town_city: coAddr.town_city,
|
|
physical_province: coAddr.province,
|
|
physical_postal_code: coAddr.postal_code,
|
|
};
|
|
const html = signedLetterHtml(directorForRender, companyForRender, date, signatureBase64);
|
|
const filename = `signed-acceptance-${companyName.replace(/[^A-Za-z0-9]/g, "_")}`;
|
|
const pdfBytes = await renderPdf(html, filename);
|
|
eml = buildSignedEml({ to, directorName, companyName, date, pdfBytes, pdfFilename: `${filename}.pdf` });
|
|
|
|
// Persist to PB (only when the renderer produced a PDF)
|
|
if (pdfBytes) {
|
|
try {
|
|
const pdfFile = new File([pdfBytes], `${filename}.pdf`, { type: "application/pdf" });
|
|
await updateRecordMultipart(
|
|
"ysterbaard_director_appointments",
|
|
appt.id,
|
|
{},
|
|
[{ field: "letter", file: pdfFile }]
|
|
);
|
|
} catch (saveErr) {
|
|
console.error("Resend: failed to persist signed PDF to PB:", saveErr);
|
|
}
|
|
}
|
|
}
|
|
|
|
await sendViaHimalaya(eml);
|
|
return NextResponse.json({ ok: true, mail });
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.error("Resend error:", msg);
|
|
return NextResponse.json({ error: msg }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
function pickAddress(
|
|
structured: { line_1?: string | null; line_2?: string | null; town_city?: string | null; province?: string | null; postal_code?: string | null },
|
|
flat?: string | null
|
|
): { line_1: string; line_2: string; town_city: string; province?: string; postal_code?: string } {
|
|
const s = (v: string | null | undefined) => (v ?? "").trim();
|
|
const line1 = s(structured.line_1);
|
|
const line2 = s(structured.line_2);
|
|
const town = s(structured.town_city);
|
|
const prov = s(structured.province);
|
|
const zip = s(structured.postal_code);
|
|
const structuredLooksReal =
|
|
(line1 && !/FIXED|TEST|PLACEHOLDER|XXX/i.test(line1)) ||
|
|
(town && !/^(JOHANNESBURG|PRETORIA|CAPE TOWN|TEST)$/i.test(town) && !/JHB/i.test(town)) ||
|
|
(zip && zip !== "2000" && zip !== "0000");
|
|
if (structuredLooksReal && (line1 || town || zip)) {
|
|
return { line_1: line1, line_2: line2, town_city: town, province: prov, postal_code: zip };
|
|
}
|
|
if (!flat) {
|
|
return { line_1: line1, line_2: line2, town_city: town, province: prov, postal_code: zip };
|
|
}
|
|
const parts = flat.split(",").map((p) => p.trim()).filter(Boolean);
|
|
if (parts.length >= 5) {
|
|
return { line_1: parts[0], line_2: "", town_city: [parts[1], parts[2]].filter(Boolean).join(", "), province: parts[3], postal_code: parts[4] };
|
|
}
|
|
if (parts.length === 4) {
|
|
return { line_1: parts[0], line_2: "", town_city: parts[1], province: parts[2], postal_code: parts[3] };
|
|
}
|
|
if (parts.length === 3) {
|
|
return { line_1: parts[0], line_2: "", town_city: parts[1], province: "", postal_code: parts[2] };
|
|
}
|
|
if (parts.length === 2) {
|
|
return { line_1: parts[0], line_2: "", town_city: parts[1], province: "", postal_code: "" };
|
|
}
|
|
return { line_1: parts[0] || "", line_2: "", town_city: "", province: "", postal_code: "" };
|
|
}
|
|
|
|
// ─── Full Consent-to-Act-as-Director template ─────────────────────────────
|
|
|
|
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;
|
|
}): string {
|
|
const esc = (s: string) => String(s ?? "")
|
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
const dir = {
|
|
name: esc(opts.directorName), id: esc(opts.directorIdNo),
|
|
email: esc(opts.directorEmail), cell: esc(opts.directorCellPhone),
|
|
addr1: esc(opts.residentialAddressLine1), addr2: esc(opts.residentialAddressLine2),
|
|
town: esc(opts.residentialTownCity), province: esc(opts.residentialProvince),
|
|
zip: esc(opts.residentialPostalCode),
|
|
};
|
|
const co = {
|
|
name: esc(opts.companyName),
|
|
p1: esc(opts.physicalAddressLine1), p2: esc(opts.physicalAddressLine2),
|
|
pt: esc(opts.physicalTownCity), pp: esc(opts.physicalProvince), pz: esc(opts.physicalPostalCode),
|
|
};
|
|
const today = new Date().toISOString().split("T")[0];
|
|
const d = new Date(today);
|
|
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>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.2; margin: 0; }
|
|
.top-block { margin: 0; padding: 0; }
|
|
.addressee-block { margin: 0; padding: 0; line-height: 1.15; }
|
|
.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; }
|
|
.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; }
|
|
table { border-collapse: collapse; }
|
|
</style></head>
|
|
<body>
|
|
|
|
<table class="top-block" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
<tr>
|
|
<td style="text-align:left;width:35%;vertical-align:top;"> </td>
|
|
<td style="text-align:right;width:65%;vertical-align:top;font-weight:bold;">${dir.name}<br/>
|
|
<span style="font-weight:normal;">${dir.addr1}${dir.addr2 ? `<br/>${dir.addr2}` : ``}<br/>
|
|
${[dir.town, dir.zip].filter(Boolean).join(", ")}${dir.province ? `<br/>${dir.province}` : ``}<br/>
|
|
${dir.email}<br/>
|
|
${dir.cell}<br/>
|
|
<br/>
|
|
<span style="font-weight:normal;">${today}</span></span>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<div class="addressee-block"><strong>The Board</strong><br/>
|
|
${co.name}<br/>
|
|
${co.p1}${co.p2 ? `<br/>${co.p2}` : ``}<br/>
|
|
${[co.pt, co.pz].filter(Boolean).join(", ")}${co.pp ? `<br/>${co.pp}` : ``}
|
|
</div>
|
|
|
|
<div class="hdr">Dear Members of The Board</div>
|
|
<div class="re">RE: CONSENT TO ACT AS DIRECTOR</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>
|
|
|
|
<div class="sign-line"> </div>
|
|
|
|
<p>
|
|
<strong>${dir.name}</strong><br/>
|
|
ID ${dir.id}
|
|
</p>
|
|
|
|
<div class="sign-link-box"><strong>To accept electronically, visit:</strong><br/>${opts.signUrl}</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 section 66(7)(b) of the Companies Act 71 of 2008
|
|
</div>
|
|
|
|
</body></html>`;
|
|
}
|
|
|
|
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>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.2; margin: 0; }
|
|
.top-block { margin: 0; padding: 0; }
|
|
.addressee-block { margin: 0; padding: 0; line-height: 1.15; }
|
|
.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; }
|
|
.footer { margin-top: 24pt; font-size: 9pt; color: #555; text-align: center; font-style: italic; }
|
|
p { margin: 6pt 0; }
|
|
table { border-collapse: collapse; }
|
|
</style></head>
|
|
<body>
|
|
|
|
<table class="top-block" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
<tr>
|
|
<td style="text-align:left;width:35%;vertical-align:top;"> </td>
|
|
<td style="text-align:right;width:65%;vertical-align:top;"><strong>${dir.name}</strong><br/>
|
|
${dir.addr1}${dir.addr2 ? `<br/>${dir.addr2}` : ``}<br/>
|
|
${[dir.town, dir.zip].filter(Boolean).join(", ")}${dir.province ? `<br/>${dir.province}` : ``}<br/>
|
|
${dir.email}<br/>
|
|
${dir.cell}<br/>
|
|
<br/>
|
|
${date}
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<div class="addressee-block"><strong>The Board</strong><br/>
|
|
${co.name}<br/>
|
|
${co.p1}${co.p2 ? `<br/>${co.p2}` : ``}<br/>
|
|
${[co.pt, co.pz].filter(Boolean).join(", ")}${co.pp ? `<br/>${co.pp}` : ``}
|
|
</div>
|
|
|
|
<div class="hdr">Dear Members of The Board</div>
|
|
<div class="re">RE: CONSENT TO ACT AS DIRECTOR</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>
|
|
<strong>${dir.name}</strong><br/>
|
|
ID ${dir.id}
|
|
</p>
|
|
|
|
<div class="footer">
|
|
Filed in terms of section 66(7)(b) of the Companies Act 71 of 2008
|
|
</div>
|
|
|
|
</body></html>`;
|
|
}
|
|
|
|
/** Render the PDF, or return null if the local renderer is down.
|
|
*
|
|
* The pdfd service on PDFD_URL is often not running. A failed render must
|
|
* never abort the resend BEFORE himalaya is called, or the letter is silently
|
|
* lost. A missing attachment is recoverable, so degrade to HTML-only. */
|
|
async function renderPdf(html: string, filename: string): Promise<Buffer | null> {
|
|
try {
|
|
const res = await fetch(`${PDFD_URL}/pdf`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ html, filename }),
|
|
signal: AbortSignal.timeout(20000),
|
|
});
|
|
if (!res.ok) {
|
|
console.error(`[appt-resend] pdfd returned ${res.status} — sending without attachment`);
|
|
return null;
|
|
}
|
|
const data = (await res.json()) as { pdf: string };
|
|
return Buffer.from(data.pdf, "base64");
|
|
} catch (e) {
|
|
console.error("[appt-resend] PDF render failed — sending without attachment:", e instanceof Error ? e.message : e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildInitialEml(opts: {
|
|
to: string; directorName: string; companyName: string; signUrl: string;
|
|
pdfBytes: Buffer | null; pdfFilename: string;
|
|
}): string {
|
|
const boundary = "----YSTERBAARD_" + Date.now();
|
|
let eml = `To: ${opts.to}\r\nFrom: SJ@Dyfins.com\r\nSubject: Consent to act as director — ${opts.companyName}\r\nMIME-Version: 1.0\r\n`;
|
|
eml += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
|
|
eml += `--${boundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n`;
|
|
eml += initialEmailBodyHtml(opts.directorName, opts.companyName, opts.signUrl);
|
|
// The PDF service is optional — fall back to a text/plain-only message so
|
|
// the letter still reaches the director when the renderer is down.
|
|
if (opts.pdfBytes && opts.pdfFilename) {
|
|
eml += `\r\n--${boundary}\r\nContent-Type: application/pdf; name="${opts.pdfFilename}"\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename="${opts.pdfFilename}"\r\n\r\n`;
|
|
eml += opts.pdfBytes.toString("base64").replace(/(.{76})/g, "$1\r\n");
|
|
}
|
|
eml += `\r\n--${boundary}--\r\n`;
|
|
return eml;
|
|
}
|
|
|
|
function initialEmailBodyHtml(directorName: string, companyName: string, signUrl: string): string {
|
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head>
|
|
<body style="font-family:Arial,sans-serif;font-size:14px;color:#222;max-width:600px;margin:40px auto;padding:0 20px;">
|
|
<div style="background:#f8f9fa;border:1px solid #ddd;border-radius:8px;padding:24px;">
|
|
<p>Good day <strong>${directorName}</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;">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>
|
|
</div>
|
|
<p>Yours sincerely,<br/><strong>The Board of ${companyName}</strong></p>
|
|
</div></body></html>`;
|
|
}
|
|
|
|
function buildSignedEml(opts: {
|
|
to: string; directorName: string; companyName: string; date: string;
|
|
pdfBytes: Buffer | null; pdfFilename: string;
|
|
}): string {
|
|
const boundary = "----YSTERBAARD_" + Date.now();
|
|
let eml = `To: ${opts.to}\r\nFrom: SJ@Dyfins.com\r\nSubject: ${opts.directorName} signed consent to act as director of ${opts.companyName}\r\nMIME-Version: 1.0\r\n`;
|
|
eml += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`;
|
|
eml += `--${boundary}\r\nContent-Type: text/html; charset="utf-8"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n`;
|
|
eml += signedEmailBodyHtml(opts.directorName, opts.companyName, opts.date);
|
|
if (opts.pdfBytes && opts.pdfFilename) {
|
|
eml += `\r\n--${boundary}\r\nContent-Type: application/pdf; name="${opts.pdfFilename}"\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename="${opts.pdfFilename}"\r\n\r\n`;
|
|
eml += opts.pdfBytes.toString("base64").replace(/(.{76})/g, "$1\r\n");
|
|
}
|
|
eml += `\r\n--${boundary}--\r\n`;
|
|
return eml;
|
|
}
|
|
|
|
function signedEmailBodyHtml(directorName: string, companyName: string, date: string): string {
|
|
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head>
|
|
<body style="font-family:Arial,sans-serif;font-size:14px;color:#222;max-width:600px;margin:40px auto;padding:0 20px;">
|
|
<div style="background:#f8f9fa;border:1px solid #ddd;border-radius:8px;padding:24px;">
|
|
<h2 style="text-align:center;color:#0a0a0a;font-size:18px;margin:0 0 16px;">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>
|
|
<p style="color:#888;font-style:italic;">[Signature on file — see attached signed letter]</p>
|
|
<p>The full signed letter is attached as a PDF.</p>
|
|
</div></body></html>`;
|
|
}
|
|
|
|
async function fetchSignatureBase64(appt: YsterbaardDirectorAppointment): Promise<string | null> {
|
|
if (!appt.signature) return null;
|
|
const url = `${PB_URL}/api/files/${appt.collectionId}/${appt.id}/${appt.signature}`;
|
|
const res = await fetch(url, { headers: PB_TOKEN ? { Authorization: PB_TOKEN } : {} });
|
|
if (!res.ok) return null;
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
return buf.toString("base64");
|
|
}
|
|
|
|
async function sendViaHimalaya(eml: string): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const child = spawn(
|
|
HIMALAYA_BIN,
|
|
himalayaSendArgs("dyfins"),
|
|
{ stdio: ["pipe", "pipe", "pipe"] }
|
|
);
|
|
let err = "";
|
|
child.stderr.on("data", (d) => { err += d.toString(); });
|
|
child.on("error", (e) => reject(e));
|
|
child.on("close", (code) => {
|
|
if (code !== 0) reject(new Error(`himalaya exited ${code}: ${err}`));
|
|
else resolve();
|
|
});
|
|
child.stdin.write(eml);
|
|
child.stdin.end();
|
|
});
|
|
}
|