// 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: , director: }] // } // // 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 = { 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(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 = {}; 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(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(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(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 = {}; 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(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 = { 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(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(); 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(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 || "")), }); }