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.
279 lines
No EOL
10 KiB
TypeScript
279 lines
No EOL
10 KiB
TypeScript
// app/api/shareholder-changes/[id]/send
|
|
// POST /api/shareholder-changes/<id>/send
|
|
//
|
|
// The section-5 equivalent of "send for signature": renders the special
|
|
// resolution of shareholders, files it on the record, and emails the PDF to
|
|
// every party so they can print, sign by hand and return it.
|
|
//
|
|
// Body (optional): { attendee_ids?: string[] } limit to specific parties
|
|
//
|
|
// Returns:
|
|
// { resolution_id, filename, saved, emailed: [...], skipped: [...],
|
|
// status, letter_error? }
|
|
//
|
|
// BEHAVIOUR NOTES
|
|
// - The letter is rendered ONCE and the same PDF goes to everyone, so every
|
|
// party is signing an identical document.
|
|
// - Re-uploading a corrected letter supersedes the previous generated_letter
|
|
// (PocketBase keys a file field by name).
|
|
// - A party with no email address is reported in `skipped` rather than
|
|
// silently dropped — the operator needs to know who has not been reached.
|
|
// - Mail failures are reported per recipient and do NOT roll back the send:
|
|
// one bad address must not stop the others from receiving the document.
|
|
// - HTTP 200 means "we processed the send". Check `emailed[].ok` and
|
|
// `skipped` for the real per-recipient outcome — a 200 alone is not a
|
|
// delivery confirmation.
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { pbFetch, updateRecord, updateRecordMultipart } from "@/lib/pb";
|
|
import { generateShareholderLetter } from "@/lib/shareholder-letter";
|
|
import { sendShareholderResolutionEmail } from "@/lib/shareholder-mail";
|
|
import {
|
|
COL,
|
|
ROLE_LABEL,
|
|
displayPartyName,
|
|
type ShareholderRole,
|
|
} from "@/lib/shareholder-changes";
|
|
|
|
interface AttendeeRec {
|
|
id: string;
|
|
name?: string;
|
|
surname?: string;
|
|
email?: string | null;
|
|
director_role?: string;
|
|
position?: string;
|
|
must_sign?: boolean;
|
|
shares?: number | null;
|
|
sign_order?: number | null;
|
|
/** Set only when the party came from the directors list. */
|
|
director?: string;
|
|
}
|
|
|
|
interface ResolutionRec {
|
|
id: string;
|
|
company?: string;
|
|
status?: string;
|
|
}
|
|
|
|
interface CompanyRec {
|
|
id: string;
|
|
enterprise_name?: string;
|
|
}
|
|
|
|
/**
|
|
* How the recipient is described in the email.
|
|
*
|
|
* Meeting attendees are the ones who sign; the transferor is the one selling;
|
|
* the transferee is the buyer. Getting these muddled tells the buyer they need
|
|
* to sign a document they have no standing to sign.
|
|
*/
|
|
function roleLabelFor(p: AttendeeRec, name: string): string {
|
|
switch (p.director_role) {
|
|
case "transferor":
|
|
return `${ROLE_LABEL.transferor} — you are selling shares`;
|
|
case "transferee":
|
|
return `${ROLE_LABEL.transferee} — you are acquiring shares`;
|
|
case "existing":
|
|
return p.must_sign
|
|
? `Shareholder (${p.position || "Director"}) — please sign`
|
|
: `Shareholder (${p.position || "Director"}) — attending`;
|
|
default:
|
|
return `${name} — party to this resolution`;
|
|
}
|
|
}
|
|
|
|
export async function POST(
|
|
req: NextRequest,
|
|
{ params }: { params: { id: string } }
|
|
) {
|
|
const id = params.id;
|
|
if (!id) {
|
|
return NextResponse.json({ error: "Missing resolution id" }, { status: 400 });
|
|
}
|
|
|
|
let onlyIds: string[] | null = null;
|
|
try {
|
|
const body = await req.json().catch(() => null);
|
|
if (body && Array.isArray(body.attendee_ids)) {
|
|
onlyIds = body.attendee_ids.filter((x: unknown) => typeof x === "string");
|
|
}
|
|
} catch {
|
|
/* empty body is fine */
|
|
}
|
|
|
|
try {
|
|
const resolution = await pbFetch<ResolutionRec>(
|
|
`/api/collections/${COL.resolutions}/records/${encodeURIComponent(id)}`
|
|
);
|
|
if (!resolution?.id) {
|
|
return NextResponse.json({ error: "Resolution not found" }, { status: 404 });
|
|
}
|
|
if (!resolution.company) {
|
|
return NextResponse.json(
|
|
{ error: "This resolution has no company linked." },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const company = await pbFetch<CompanyRec>(
|
|
`/api/collections/${COL.companies}/records/${encodeURIComponent(resolution.company)}`
|
|
);
|
|
const companyName = company?.enterprise_name ?? "the Company";
|
|
|
|
// ─── Parties ────────────────────────────────────────────────────────────
|
|
// EVERY attendee row, not just the transfer sides. The meeting attendees
|
|
// are the people who actually pass and sign the resolution; the buyer is
|
|
// not a shareholder of the company and would never sign it.
|
|
const attendeesRes = await pbFetch<{ items: AttendeeRec[] }>(
|
|
`/api/collections/${COL.attendees}/records`,
|
|
{ perPage: 500, filter: `resolution="${id}"`, sort: "sign_order" }
|
|
);
|
|
const all = (attendeesRes.items || [])
|
|
.filter((a) => a.director_role === "existing" || a.director_role === "transferor" || a.director_role === "transferee")
|
|
.filter((a) => !onlyIds || onlyIds.includes(a.id));
|
|
|
|
if (all.length === 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error:
|
|
"No parties to send to. This resolution has no attendee rows" +
|
|
(onlyIds ? " matching the selected attendees." : "."),
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// ─── Render ─────────────────────────────────────────────────────────────
|
|
// Rendering is essential here (the PDF is the deliverable), so a failure is
|
|
// fatal to this request — but it must NOT have half-sent anything, so it
|
|
// happens before the first email goes out.
|
|
let letter;
|
|
try {
|
|
letter = await generateShareholderLetter(id);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Unknown error";
|
|
return NextResponse.json(
|
|
{
|
|
error: `Could not generate the resolution letter, so nothing was sent: ${message}`,
|
|
letter_error: message,
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
|
|
if (letter.unfilledPlaceholders.length > 0) {
|
|
console.warn(
|
|
`[shareholder-changes] ${id} sent with unfilled placeholders:`,
|
|
letter.unfilledPlaceholders.join(", ")
|
|
);
|
|
}
|
|
|
|
// ─── File it on the record ──────────────────────────────────────────────
|
|
let saved = false;
|
|
let saveError: string | undefined;
|
|
try {
|
|
const file = new File([new Uint8Array(letter.pdf)], letter.filename, {
|
|
type: "application/pdf",
|
|
});
|
|
await updateRecordMultipart(
|
|
COL.resolutions,
|
|
id,
|
|
{},
|
|
[{ field: "generated_letter", file }]
|
|
);
|
|
saved = true;
|
|
} catch (err) {
|
|
// Filing is a convenience, not a precondition: the mail still goes out.
|
|
saveError = err instanceof Error ? err.message : String(err);
|
|
console.error("[shareholder-changes] could not file generated_letter:", saveError);
|
|
}
|
|
|
|
// ─── Email everyone ─────────────────────────────────────────────────────
|
|
const attachment = { filename: letter.filename, base64: letter.pdf.toString("base64") };
|
|
|
|
const emailed: Array<{
|
|
attendee_id: string;
|
|
name: string;
|
|
to: string | null;
|
|
ok: boolean;
|
|
error?: string;
|
|
}> = [];
|
|
const skipped: Array<{ attendee_id: string; name: string; reason: string }> = [];
|
|
|
|
// One email per ADDRESS, not per row. A director who attended AND is the
|
|
// transferor appears twice above; sending them two copies of the same
|
|
// resolution would be confusing and would skew their read receipts.
|
|
const seenEmails = new Set<string>();
|
|
|
|
for (const p of all) {
|
|
// The attendee's `director` relation is set only when the party came from
|
|
// the directors list, so it is what distinguishes a person from a company
|
|
// here. Passing "company" unconditionally would skip the title-casing and
|
|
// email "SUNE PIETERS" while the attached PDF reads "Sune Pieters".
|
|
const source = p.director ? "director" : "company";
|
|
const name = displayPartyName(source, p.name ?? "", p.surname ?? "");
|
|
|
|
if (!p.email || !p.email.trim()) {
|
|
skipped.push({
|
|
attendee_id: p.id,
|
|
name,
|
|
reason: "no email address on file",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const to = p.email.trim().toLowerCase();
|
|
if (seenEmails.has(to)) continue;
|
|
seenEmails.add(to);
|
|
|
|
const result = await sendShareholderResolutionEmail({
|
|
to: p.email.trim(),
|
|
party_name: name,
|
|
company_name: companyName,
|
|
role_label: roleLabelFor(p, name),
|
|
attachment,
|
|
});
|
|
|
|
emailed.push({
|
|
attendee_id: p.id,
|
|
name,
|
|
to: p.email.trim(),
|
|
ok: result.ok,
|
|
error: result.error,
|
|
});
|
|
}
|
|
|
|
// ─── Status ─────────────────────────────────────────────────────────────
|
|
// Only advance when at least one message actually left. A run where every
|
|
// address failed is still "draft" — nothing has left the building.
|
|
const anySent = emailed.some((e) => e.ok);
|
|
let status = resolution.status ?? "draft";
|
|
if (anySent && status === "draft") {
|
|
status = "pending_signatures";
|
|
try {
|
|
await updateRecord(COL.resolutions, id, { status });
|
|
} catch (err) {
|
|
console.error("[shareholder-changes] could not update status:", err);
|
|
}
|
|
}
|
|
|
|
const failed = emailed.filter((e) => !e.ok);
|
|
return NextResponse.json({
|
|
resolution_id: id,
|
|
filename: letter.filename,
|
|
saved,
|
|
...(saveError ? { save_error: saveError } : {}),
|
|
emailed,
|
|
skipped,
|
|
sent: emailed.length - failed.length,
|
|
failed: failed.length,
|
|
unfilled_placeholders: letter.unfilledPlaceholders,
|
|
status,
|
|
});
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : "Unexpected error" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
} |