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.
150 lines
No EOL
5.6 KiB
TypeScript
150 lines
No EOL
5.6 KiB
TypeScript
// app/api/shareholder-changes/create
|
|
// POST /api/shareholder-changes/create
|
|
//
|
|
// Creates a draft RESOLUTION PASSED BY THE SHAREHOLDERS for a change of
|
|
// shareholding. Creates:
|
|
// - 1 resolution row (kind = "shareholder_change", status = "draft")
|
|
// - 1 attendee row per meeting attendee (director_role = "existing")
|
|
// - 1 attendee row per transferor / transferee
|
|
//
|
|
// Body:
|
|
// {
|
|
// company_id: string,
|
|
// meeting_date: "YYYY-MM-DD",
|
|
// path: "letter_generated" | "letters_received",
|
|
// votes_percent: string,
|
|
// attendees: [{ director_id, name, surname, email, position, signs }],
|
|
// transferors: [{ source, source_id, name, surname, email, id_number,
|
|
// shares_held, shares, certificate_no, percentage,
|
|
// distinctive_from, distinctive_to,
|
|
// shares_retained, retained_from, retained_to,
|
|
// consideration, payment_method }],
|
|
// transferees: [{ source, source_id, name, surname, email, id_number, shares }],
|
|
// notes?: string,
|
|
// }
|
|
//
|
|
// Returns: { resolution_id, attendee_ids, totals }
|
|
//
|
|
// The endpoint does NOT generate or send anything — it produces a draft ready
|
|
// for the operator to preview, then mail from the row's action menu, exactly
|
|
// like section 4. Nothing is emailed automatically.
|
|
//
|
|
// THREE SEPARATE LISTS
|
|
// -------------------
|
|
// attendees who was present to approve (directors only)
|
|
// transferors who is selling (director OR company, one or more)
|
|
// transferees who is buying (director OR company, one or more)
|
|
//
|
|
// A director can be an attendee AND a transferor; they become two attendee rows
|
|
// because the letter states both things about them. Conflating the lists would
|
|
// put the wrong names in the table at the top of a legal document.
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { COL } from "@/lib/shareholder-changes";
|
|
import {
|
|
attendeeRowFields,
|
|
parseChangeBody,
|
|
serialiseDetail,
|
|
str,
|
|
} from "@/lib/shareholder-change-payload";
|
|
import { createRecord, fetchCollection, type PBRecord } from "@/lib/pb";
|
|
|
|
interface IncomingBody {
|
|
company_id?: string;
|
|
meeting_date?: string;
|
|
path?: string;
|
|
votes_percent?: string;
|
|
attendees?: unknown;
|
|
transferors?: unknown;
|
|
transferees?: unknown;
|
|
notes?: string;
|
|
}
|
|
|
|
function safeEscape(s: string): string {
|
|
return s.replace(/'/g, "''");
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let body: IncomingBody;
|
|
try {
|
|
body = (await req.json()) as IncomingBody;
|
|
} catch {
|
|
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
}
|
|
|
|
// ─── Validate ─────────────────────────────────────────────────────────────
|
|
const companyId = str(body.company_id);
|
|
if (!companyId) {
|
|
return NextResponse.json({ error: "company_id is required" }, { status: 400 });
|
|
}
|
|
|
|
// The same parser and the same rules the edit path uses, so a resolution can
|
|
// never be created in a shape that an edit would reject.
|
|
let change: ReturnType<typeof parseChangeBody>;
|
|
try {
|
|
change = parseChangeBody(body as Record<string, unknown>);
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : "Invalid party data" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
const { attendees, transferors, transferees, sold, acquired, signers } = change;
|
|
|
|
try {
|
|
try {
|
|
const found = await fetchCollection<PBRecord>(COL.companies, {
|
|
filter: `id="${safeEscape(companyId)}"`,
|
|
perPage: 1,
|
|
});
|
|
if (!found.items[0]) throw new Error("not found");
|
|
} catch {
|
|
return NextResponse.json({ error: `Company ${companyId} not found` }, { status: 404 });
|
|
}
|
|
|
|
// ─── Resolution row ─────────────────────────────────────────────────────
|
|
// The operator's free-text note travels INSIDE the JSON document. Appending
|
|
// it after the JSON made the whole `notes` field unparseable, which silently
|
|
// cost us every certificate-level detail in the letter.
|
|
const notesPayload = serialiseDetail(companyId, body.votes_percent, change, str(body.notes, 2000));
|
|
|
|
const resolution = await createRecord<PBRecord>(COL.resolutions, {
|
|
company: companyId,
|
|
meeting_date: body.meeting_date,
|
|
status: "draft",
|
|
path: body.path,
|
|
kind: "shareholder_change",
|
|
resigned_directors: [],
|
|
appointed_directors: [],
|
|
notes: notesPayload,
|
|
});
|
|
const resolutionId = resolution.id;
|
|
|
|
// ─── Attendee rows ───────────────────────────────────────────────────────
|
|
const attendeeIds: string[] = [];
|
|
for (const row of attendeeRowFields(resolutionId, String(body.meeting_date), change)) {
|
|
const created = await createRecord<PBRecord>(COL.attendees, row);
|
|
attendeeIds.push(created.id);
|
|
}
|
|
|
|
return NextResponse.json({
|
|
resolution_id: resolutionId,
|
|
attendee_ids: attendeeIds,
|
|
totals: {
|
|
attendees: attendees.length,
|
|
signers: signers.length,
|
|
transferors: transferors.length,
|
|
transferees: transferees.length,
|
|
shares_sold: sold,
|
|
shares_acquired: acquired,
|
|
},
|
|
// Parties that cannot be emailed — surfaced now, not at send time.
|
|
without_email: change.withoutEmail,
|
|
});
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : "Unexpected error" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
} |