Workflow-App/Workflow/app/api/cipc/save-selection/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

499 lines
No EOL
18 KiB
TypeScript

// app/api/cipc/save-selection/route.ts
//
// POST /api/cipc/save-selection
// Body: {
// companies: [{
// enterprise_no, enterprise_name, enterprise_status?, enterprise_type?,
// registration_date?, physical_address?, postal_address?,
// is_client?: boolean, source?
// }],
// directors: [{
// id_no?, id_no_masked?, first_names?, surname?,
// director_type?, is_client?: boolean, source?
// }],
// links: [{ company: <index into companies>, director: <index into directors> }]
// }
//
// Creates whatever is missing, in this order:
// 1. companies (skip any whose Enterprise No. already exists)
// 2. directors (skip any whose ID already exists — matched on the real
// number OR the masked form)
// 3. links (skip any that already exist)
//
// ── WHY THIS EXISTS INSTEAD OF REUSING /api/pb/records/bulk-create ───────
// That endpoint is what the BizPortal tab calls, and it has three problems
// this route deliberately does not inherit:
// • it hardcodes `is_client = true` on everything it creates;
// • `id_no_masked` is not in its field allowlist, so a masked ID is
// silently discarded, and then `id_no` is *derived backwards* from the
// fabricated number;
// • BizPortal converts a masked ID to zeros before sending it
// ("821011XXXX08X" → "8210110000080"), which is exactly what produced
// the damaged records already sitting in ysterbaard_directors.
//
// ── THE MASKED-ID RULE (agreed with SJ) ──────────────────────────────────
// SJ wants the masked number SAVED so he can correct it later. We store it
// in `id_no_masked` and leave `id_no` EMPTY. That still saves the masked
// number and keeps it findable, while making it impossible for a later
// lookup on the real number to miss and create a duplicate. If the source
// gave us a real 13-digit ID we verify its SA ID check digit and only then
// write it to `id_no`.
//
// This route uses lib/pb (self-healing token) rather than reading
// PB_TOKEN out of .env.local like the older single-add route does.
import { NextRequest, NextResponse } from "next/server";
import { createRecord, fetchCollection, pbFetch, recomputeDirectorCompanyCounts, updateRecord, PBRecord } from "@/lib/pb";
export const dynamic = "force-dynamic";
const COMPANIES = "ysterbaard_companies";
const DIRECTORS = "ysterbaard_directors";
const JUNCTION = "pbc_2497518006"; // ysterbaard_company_directors
const MAX_COMPANIES = 100;
const MAX_DIRECTORS = 200;
const MAX_LINKS = 400;
// ── helpers ─────────────────────────────────────────────────────────────
/** Mirror of the canonicalisation in /api/cipc/add-company. */
function canonicaliseEnterpriseNo(raw: string): string {
const t = (raw || "").trim().toUpperCase();
if (!t) return "";
const slash = t.match(/^(\d{4})\/(\d{6,7})(?:\/\d{2})?$/);
if (slash) return `K${slash[1]}${slash[2]}`;
if (/^[KMB]\d{10,12}$/.test(t)) return t;
if (/^\d{10,12}$/.test(t)) return `K${t}`;
return t;
}
/** SA ID check digit. A full number that fails this is a placeholder. */
function isValidSaId(s: string): boolean {
const v = (s || "").replace(/\s/g, "");
if (!/^\d{13}$/.test(v)) return false;
const d = v.split("").map(Number);
let sum = 0;
for (let i = 0; i < 12; i++) {
let n = d[i];
if (i % 2 === 1) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
}
return (10 - (sum % 10)) % 10 === d[12];
}
/** Build the masked form of a full ID. Used for matching + display only. */
function toMasked(s: string): string {
const v = (s || "").replace(/\s/g, "");
if (!/^\d{13}$/.test(v)) return "";
return v.slice(0, 6) + "XXXX" + v.slice(10, 12) + "X";
}
/** PB filter values are double-quoted; escape embedded quotes. */
function q(v: string): string {
return `"${String(v ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
const today = () => new Date().toISOString().slice(0, 10);
// ── payload types ───────────────────────────────────────────────────────
interface CompanyIn {
enterprise_no?: string;
enterprise_name?: string;
enterprise_status?: string;
enterprise_type?: string;
registration_date?: string;
physical_address?: string;
postal_address?: string;
is_client?: boolean;
source?: string;
}
interface DirectorIn {
id_no?: string;
id_no_masked?: string;
first_names?: string;
surname?: string;
director_type?: string;
is_client?: boolean;
source?: string;
}
interface LinkIn {
company?: number;
director?: number;
director_type?: string;
}
/** PB date fields come back as full timestamps ("2024-02-12 00:00:00.000Z").
* Compare on the date part so an unchanged date isn't rewritten every save. */
function datePart(s: unknown): string {
const v = String(s ?? "");
const m = v.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : "";
}
interface ItemResult {
ok: boolean;
status: "created" | "existing" | "updated" | "skipped" | "failed";
id?: string;
label: string;
error?: string;
changed?: string[];
}
// ── route ───────────────────────────────────────────────────────────────
export async function POST(req: NextRequest) {
let body: {
companies?: CompanyIn[];
directors?: DirectorIn[];
links?: LinkIn[];
};
try {
body = await req.json();
} catch {
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
}
const companies = Array.isArray(body.companies) ? body.companies : [];
const directors = Array.isArray(body.directors) ? body.directors : [];
const links = Array.isArray(body.links) ? body.links : [];
if (companies.length === 0 && directors.length === 0) {
return NextResponse.json(
{ ok: false, error: "Nothing selected to save." },
{ status: 400 }
);
}
if (companies.length > MAX_COMPANIES || directors.length > MAX_DIRECTORS) {
return NextResponse.json(
{ ok: false, error: `Too many rows (max ${MAX_COMPANIES} companies / ${MAX_DIRECTORS} directors).` },
{ status: 400 }
);
}
const companyResults: ItemResult[] = [];
const directorResults: ItemResult[] = [];
const linkResults: ItemResult[] = [];
// companyIdByIndex[i] = PB record id (created OR pre-existing).
const companyIdByIndex: (string | null)[] = [];
const directorIdByIndex: (string | null)[] = [];
// ── 1. companies ──────────────────────────────────────────────────────
for (let i = 0; i < companies.length; i++) {
const c = companies[i] || {};
const entNo = canonicaliseEnterpriseNo(c.enterprise_no || "");
const name = (c.enterprise_name || "").trim();
const label = name || entNo || `company #${i}`;
if (!entNo || !/^[A-Z0-9]{5,20}$/.test(entNo)) {
companyResults.push({ ok: false, status: "failed", label, error: "Invalid Enterprise Number" });
companyIdByIndex.push(null);
continue;
}
if (!name) {
companyResults.push({ ok: false, status: "failed", label: entNo, error: "Company name missing" });
companyIdByIndex.push(null);
continue;
}
// Build the desired field set first — used for BOTH create and update.
const record: Record<string, unknown> = {
enterprise_no: entNo,
enterprise_name: name,
};
const put = (k: keyof CompanyIn, v: unknown) => {
const s = (v as string | undefined)?.trim();
if (s) record[k as string] = s;
};
put("enterprise_status", c.enterprise_status);
put("enterprise_type", c.enterprise_type);
put("registration_date", c.registration_date);
put("physical_address", c.physical_address);
put("postal_address", c.postal_address);
try {
const existing = await fetchCollection<PBRecord>(COMPANIES, {
perPage: 1,
filter: `enterprise_no = ${q(entNo)}`,
});
if (existing.items.length > 0) {
// Already on file — APPLY the fresh CIPC values rather than skipping.
// Previously this reported "existing" and did nothing, so ticking a
// company that was already saved silently changed nothing (SJ hit
// this on 2026-10-09).
const rec = existing.items[0];
const patch: Record<string, unknown> = {};
const changed: string[] = [];
for (const [k, v] of Object.entries(record)) {
if (k === "enterprise_no") continue; // the lookup key, never changes
const next = String(v).trim();
if (!next) continue; // never blank out a stored value with nothing
const prev = (rec as any)[k];
const differs =
k === "registration_date" || k === "first_seen" || k === "last_checked"
? datePart(prev) !== datePart(next)
: String(prev ?? "").trim() !== next;
if (differs) {
patch[k] = next;
changed.push(k);
}
}
// Client status and is_active are the user's to set — a CIPC lookup
// must never silently flip them.
const wantClient = c.is_client ? "active_client" : "non_client";
if (c.is_client === true && String(rec.client_status ?? "") !== wantClient) {
patch.client_status = wantClient;
changed.push("client_status");
}
patch.last_checked = today();
companyIdByIndex.push(rec.id);
if (changed.length > 0) {
await updateRecord<PBRecord>(COMPANIES, rec.id, patch);
companyResults.push({
ok: true,
status: "updated",
id: rec.id,
label,
changed,
});
} else {
companyResults.push({ ok: true, status: "existing", id: rec.id, label });
}
continue;
}
record.is_active = false;
record.first_seen = today();
record.last_checked = today();
// Default: NOT a client. SJ ticks the Client box explicitly when a
// company genuinely is one.
record.client_status = c.is_client ? "active_client" : "non_client";
record.source = c.source || "cipc:save-selection";
const created = await createRecord<PBRecord>(COMPANIES, record);
companyIdByIndex.push(created.id);
companyResults.push({ ok: true, status: "created", id: created.id, label });
} catch (e) {
companyIdByIndex.push(null);
companyResults.push({
ok: false,
status: "failed",
label,
error: e instanceof Error ? e.message : "Unknown error",
});
}
}
// ── 2. directors ──────────────────────────────────────────────────────
for (let i = 0; i < directors.length; i++) {
const d = directors[i] || {};
const rawId = (d.id_no || "").replace(/\s/g, "");
const fullName = `${(d.first_names || "").trim()} ${(d.surname || "").trim()}`.trim();
const label = fullName || rawId || `director #${i}`;
const first = (d.first_names || "").trim();
const lastName = (d.surname || "").trim();
// The masked number SJ asked us to keep.
let masked = (d.id_no_masked || "").replace(/\s/g, "").toUpperCase();
// A full ID is only trusted if its check digit agrees. Anything else is
// treated as masked and never written to `id_no`.
const realId = isValidSaId(rawId) ? rawId : "";
if (realId) masked = toMasked(realId);
if (!realId && !masked) {
directorResults.push({ ok: false, status: "failed", label, error: "No usable ID number" });
directorIdByIndex.push(null);
continue;
}
if (!fullName) {
directorResults.push({ ok: false, status: "skipped", label: masked || realId, error: "No name — skipped" });
directorIdByIndex.push(null);
continue;
}
try {
// Match on the real number OR either masked form, so we never create a
// second record for a person we already hold.
const orFilter = [realId, masked].filter(Boolean).map((v) => `id_no = ${q(v)} || id_no_masked = ${q(v)}`).join(" || ");
const existing = await fetchCollection<PBRecord>(DIRECTORS, {
perPage: 1,
filter: orFilter,
});
if (existing.items.length > 0) {
// Same as companies: apply what we learned instead of skipping.
const rec = existing.items[0];
const patch: Record<string, unknown> = {};
const changed: string[] = [];
if (first) {
if (String(rec.first_names ?? "").trim() !== first) {
patch.first_names = first;
changed.push("first_names");
}
}
if (lastName && String(rec.surname ?? "").trim() !== lastName) {
patch.surname = lastName;
changed.push("surname");
}
if ((d.director_type || "Director") !== String(rec.director_type ?? "")) {
patch.director_type = (d.director_type || "Director").trim();
changed.push("director_type");
}
// Fill a missing masked form, but NEVER write a masked/fabricated value
// into id_no — a real number typed by SJ must not be overwritten.
if (masked && !String(rec.id_no_masked ?? "").trim()) {
patch.id_no_masked = masked;
changed.push("id_no_masked");
}
patch.last_checked = today();
directorIdByIndex.push(rec.id);
if (changed.length > 0) {
await updateRecord<PBRecord>(DIRECTORS, rec.id, patch);
directorResults.push({ ok: true, status: "updated", id: rec.id, label, changed });
await recomputeDirectorCompanyCounts(rec.id);
} else {
directorResults.push({ ok: true, status: "existing", id: rec.id, label });
}
continue;
}
const record: Record<string, unknown> = {
surname: lastName,
first_names: first,
director_type: (d.director_type || "Director").trim(),
is_client: !!d.is_client,
first_seen: today(),
last_checked: today(),
total_companies: 0,
active_companies: 0,
inactive_companies: 0,
source: d.source || "cipc:save-selection",
};
// `id_no` stays EMPTY when the source only gave us a masked number.
if (realId) record.id_no = realId;
if (masked) record.id_no_masked = masked;
if (!realId) {
record.notes = "ID number masked by the CIPC source — add the real number when known.";
}
const created = await createRecord<PBRecord>(DIRECTORS, record);
directorIdByIndex.push(created.id);
directorResults.push({
ok: true,
status: "created",
id: created.id,
label: realId ? label : `${label} (needs ID)`,
});
} catch (e) {
directorIdByIndex.push(null);
directorResults.push({
ok: false,
status: "failed",
label,
error: e instanceof Error ? e.message : "Unknown error",
});
}
}
// ── 3. links ──────────────────────────────────────────────────────────
// Every director this batch touches gets its cached counters refreshed from
// the junction table at the end, so the Directors tab can never drift.
const touchedDirectors = new Set<string>();
const cappedLinks = links.slice(0, MAX_LINKS);
for (const l of cappedLinks) {
const ci = typeof l.company === "number" ? l.company : -1;
const di = typeof l.director === "number" ? l.director : -1;
const companyId = ci >= 0 ? companyIdByIndex[ci] : null;
const directorId = di >= 0 ? directorIdByIndex[di] : null;
if (!companyId || !directorId) {
linkResults.push({
ok: false,
status: "skipped",
label: `link ${ci}→${di}`,
error: "Company or director was not saved, so no link was made",
});
continue;
}
try {
const existing = await pbFetch<{ items: PBRecord[] }>(
`/api/collections/${JUNCTION}/records?filter=${encodeURIComponent(
`company = ${q(companyId)} && director = ${q(directorId)}`
)}&perPage=1`
);
if (Array.isArray(existing.items) && existing.items.length > 0) {
touchedDirectors.add(directorId);
linkResults.push({ ok: true, status: "existing", id: existing.items[0].id, label: `${ci}→${di}` });
continue;
}
const created = await createRecord<PBRecord>(JUNCTION, {
company: companyId,
director: directorId,
director_type: (l.director_type || "Director").trim(),
});
touchedDirectors.add(directorId);
linkResults.push({ ok: true, status: "created", id: created.id, label: `${ci}→${di}` });
} catch (e) {
linkResults.push({
ok: false,
status: "failed",
label: `${ci}→${di}`,
error: e instanceof Error ? e.message : "Unknown error",
});
}
}
const count = (rs: ItemResult[], s: ItemResult["status"]) =>
rs.filter((r) => r.status === s).length;
// Keep the Directors tab counters in step with the junction table.
await Promise.all(
[...touchedDirectors].map((id) =>
recomputeDirectorCompanyCounts(id).catch((e: unknown) => {
console.error("[cipc:save-selection] counter recompute failed for", id, e);
})
)
);
return NextResponse.json({
ok: true,
companies: {
created: count(companyResults, "created"),
updated: count(companyResults, "updated"),
existing: count(companyResults, "existing"),
failed: count(companyResults, "failed"),
results: companyResults,
},
directors: {
created: count(directorResults, "created"),
updated: count(directorResults, "updated"),
existing: count(directorResults, "existing"),
skipped: count(directorResults, "skipped"),
failed: count(directorResults, "failed"),
results: directorResults,
},
links: {
created: count(linkResults, "created"),
existing: count(linkResults, "existing"),
skipped: count(linkResults, "skipped"),
failed: count(linkResults, "failed"),
results: linkResults,
},
saved_enterprise_nos: companies
.map((c, i) => ({ c, i }))
.filter(({ c, i }) => companyIdByIndex[i] && c?.enterprise_no)
.map(({ c }) => canonicaliseEnterpriseNo(c.enterprise_no || "")),
});
}