Workflow-App/Workflow/app/api/shareholder-changes/[id]/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

255 lines
No EOL
8.9 KiB
TypeScript

// app/api/shareholder-changes/[id]
//
// GET /api/shareholder-changes/<id> → the saved draft, ready to re-open
// in the wizard
// PATCH /api/shareholder-changes/<id> → apply an edit
//
// WHY EDIT EXISTS
// ---------------
// An operator who mistypes a certificate number used to have exactly one option:
// delete the draft and rebuild it from scratch, re-entering every party. That is
// how mistakes get carried into a signed legal document — or how a nearly-right
// draft gets abandoned. The wizard now re-opens on the same six steps, filled
// from what was saved, so a correction is a correction.
//
// WHAT AN EDIT DOES TO STORAGE
// ----------------------------
// The resolution row keeps its id, its uploaded files (`generated_letter`,
// `signed_letter`) and its status. Its attendee rows are DELETED and rebuilt from
// the submitted lists, because those rows are a projection of three separate
// lists (meeting attendees, sellers, buyers) and reconciling them in place is a
// far more error-prone way to express "add a buyer, remove a seller".
//
// Editing is refused once the resolution has left `draft`. A resolution that has
// been mailed or signed is a record of what was sent — correcting it there
// silently would make the stored letter differ from the one the parties hold.
import { NextRequest, NextResponse } from "next/server";
import { COL, parseShareholderChangeDetail } from "@/lib/shareholder-changes";
import {
attendeeRowFields,
parseChangeBody,
serialiseDetail,
} from "@/lib/shareholder-change-payload";
import {
createRecord,
deleteRecord,
fetchCollection,
fetchRecord,
updateRecord,
type PBRecord,
} from "@/lib/pb";
interface ResolutionRec extends PBRecord {
company?: string;
meeting_date?: string;
status?: string;
path?: string;
notes?: string;
}
/** Statuses the operator may still correct. */
const EDITABLE = new Set(["draft"]);
async function loadResolution(id: string): Promise<ResolutionRec | null> {
try {
return (await fetchRecord<ResolutionRec>(COL.resolutions, id)) ?? null;
} catch {
return null;
}
}
// ─── GET ─────────────────────────────────────────────────────────────────────
export async function GET(
_req: NextRequest,
ctx: { params: Promise<{ id: string }> }
) {
const { id } = await ctx.params;
const resolution = await loadResolution(id);
if (!resolution) {
return NextResponse.json({ error: `Resolution ${id} not found` }, { status: 404 });
}
const detail = parseShareholderChangeDetail(resolution.notes);
if (!detail) {
return NextResponse.json(
{
error:
"This resolution has no readable shareholder-change detail, so it " +
"cannot be opened for editing. It may predate the detail format.",
},
{ status: 422 }
);
}
// Safe: `detail` parsed, so `notes` is a JSON object. Guarded anyway so a
// hand-edited field cannot throw a 500 out of an ordinary read.
let note = "";
try {
const raw = JSON.parse(resolution.notes ?? "{}") as { note?: string };
note = typeof raw.note === "string" ? raw.note : "";
} catch {
note = "";
}
// Attendees saved before the signature block carried ID / cell. Backfill from
// the director records rather than making the operator re-pick every attendee
// just to get their details onto the page — the letter recites them.
const attendeeIds = detail.attendees.map((a) => a.director_id).filter(Boolean);
const byId = new Map<string, { id_no?: string | null; telephone?: string | null }>();
if (attendeeIds.length > 0) {
try {
const dirs = await fetchCollection<PBRecord & {
id_no?: string | null;
telephone?: string | null;
}>(COL.directors, {
filter: attendeeIds.map((id) => `id="${id.replace(/"/g, '\\"')}"`).join(" || "),
perPage: 200,
});
for (const d of dirs.items) byId.set(d.id, d);
} catch {
// A lookup failure must not block the edit; the details simply stay
// blank and the signature block prints fewer lines.
}
}
const attendees = detail.attendees.map((a) => {
const rec = byId.get(a.director_id);
return {
...a,
id_number: a.id_number ?? rec?.id_no ?? null,
cell: a.cell ?? rec?.telephone ?? null,
};
});
return NextResponse.json({
id: resolution.id,
company_id: resolution.company ?? "",
meeting_date: (resolution.meeting_date ?? "").slice(0, 10),
path: resolution.path ?? "letter_generated",
status: resolution.status ?? "draft",
editable: EDITABLE.has(resolution.status ?? "draft"),
votes_percent: detail.votes_percent ?? "",
note,
attendees,
transferors: detail.parties.filter((p) => p.role === "transferor"),
transferees: detail.parties.filter((p) => p.role === "transferee"),
});
}
// ─── PATCH ───────────────────────────────────────────────────────────────────
export async function PATCH(
req: NextRequest,
ctx: { params: Promise<{ id: string }> }
) {
const { id } = await ctx.params;
let body: Record<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const resolution = await loadResolution(id);
if (!resolution) {
return NextResponse.json({ error: `Resolution ${id} not found` }, { status: 404 });
}
const status = resolution.status ?? "draft";
if (!EDITABLE.has(status)) {
return NextResponse.json(
{
error:
`This resolution is ${status.replace(/_/g, " ")} and can no longer be ` +
`edited. A resolution that has been mailed or signed is a record of ` +
`what was sent — create a corrected resolution instead.`,
},
{ status: 409 }
);
}
let change: ReturnType<typeof parseChangeBody>;
try {
change = parseChangeBody(body);
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Invalid party data" },
{ status: 400 }
);
}
const meeting_date = String(body.meeting_date).slice(0, 10);
const path = String(body.path);
// An edit may move the resolution to a different company — the wizard lets you
// change it — so the company is re-checked rather than assumed.
const companyId = String(body.company_id ?? resolution.company ?? "");
if (!companyId) {
return NextResponse.json({ error: "company_id is required" }, { status: 400 });
}
try {
const found = await fetchCollection<PBRecord>(COL.companies, {
filter: `id="${companyId.replace(/"/g, '\\"')}"`,
perPage: 1,
});
if (!found.items[0]) throw new Error("not found");
} catch {
return NextResponse.json({ error: `Company ${companyId} not found` }, { status: 404 });
}
try {
// ─── Resolution row ─────────────────────────────────────────────────────
// The company relation changes only when the operator actually moved it;
// leaving it out keeps PocketBase from touching the field.
const fields: Record<string, unknown> = {
meeting_date,
path,
notes: serialiseDetail(
companyId,
body.votes_percent,
change,
typeof body.notes === "string" ? body.notes : ""
),
};
if (companyId !== (resolution.company ?? "")) fields.company = companyId;
await updateRecord(COL.resolutions, id, fields);
// ─── Attendee rows: rebuild ─────────────────────────────────────────────
// Only rows belonging to THIS resolution are touched.
const existing = await fetchCollection<PBRecord>(COL.attendees, {
filter: `resolution="${id.replace(/"/g, '\\"')}"`,
perPage: 200,
});
for (const row of existing.items) {
await deleteRecord(COL.attendees, row.id);
}
const rows = attendeeRowFields(id, meeting_date, change);
for (const row of rows) {
await createRecord(COL.attendees, row);
}
return NextResponse.json({
resolution_id: id,
attendee_ids: rows.map(() => "").filter(Boolean),
totals: {
attendees: change.attendees.length,
signers: change.signers.length,
transferors: change.transferors.length,
transferees: change.transferees.length,
shares_sold: change.sold,
shares_acquired: change.acquired,
},
without_email: change.withoutEmail,
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Unexpected error" },
{ status: 500 }
);
}
}