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.
499 lines
No EOL
18 KiB
TypeScript
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 || "")),
|
|
});
|
|
} |