Workflow-App/Workflow/app/api/shareholder-changes/create/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

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