Workflow-App/Workflow/app/api/shareholder-changes/[id]/send/route.ts
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
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.
2026-10-18 15:21:45 +02:00

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 }
);
}
}