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.
255 lines
No EOL
8.9 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
} |