Workflow-App/Workflow/app/api/cipc/search/route.ts
sjcoetzeesj-coder 21db4d0e64 fix: resolve all 7 type errors that broke the production build
next build runs 'Linting and checking validity of types' and fails on
the first type error, so the Coolify image build died at
app/api/cipc/search/route.ts:640. These errors were pre-existing but
only surface on next build (npm run dev does not type check).

- cipc/search/route.ts: coerce spawnSync stdout/stderr with toString();
  result is typed as the un-overloaded return, so they are string|Buffer
- group-report pdf+email: use fetchCollection<YsterbaardGroup> instead of
  an invalid cast; normalise description null -> undefined for ReportData
- CipcExplorer (cipc + cipc-2): split the state ternary into two returns;
  the RowState union correlates state with differs, so 'diff' | 'match'
  matched neither variant
- ComplianceTreeGrid: ar_non_compliance_date is optional; the API omits it
  and this component never reads it

Verified: tsc --noEmit 0 errors, next build exit 0, standalone output ok.
2026-10-18 15:48:50 +02:00

732 lines
26 KiB
TypeScript

// app/api/cipc/search/route.ts
//
// POST /api/cipc/search
// Body: { kind: "enterprise_no" | "director_id_no", query: string }
//
// For Enterprise No. search the .py expects a *company name* (it looks up the
// K-number from PocketBase). To honour SJ's intent ("search BizPortal directly,
// PB shouldn't matter"), we resolve K-number inputs here at the API edge:
//
// K-number pattern (e.g. K2018435959, M2004008102, B2020000123,
// or unprefixed 2022808158 / 2022/808158/08)
// → look up enterprise_name via PB `enterprise_no` field
// → pass the name to the script
// anything else → pass straight through as a company name
//
// BizPortal itself is still hit with the K-number (the .py resolves name→K
// then searches BizPortal), so PB is just a convenience index — not a gate.
// If PB doesn't know the K, we still hand the raw query to the script (which
// will then try to look it up itself and likely fail with a clear "no PB record
// matched" error).
//
// Director ID No. searches (13-digit SA ID) go straight to the .py as-is.
//
// ENRICHMENT: after the script returns, we extract every Enterprise Number
// mentioned in the captured markdown and look each one up in
// `ysterbaard_companies` (PB). The enrichment is returned in the response as
// `collection_matches: { [ent_no]: { in_collection, pb_id?, pb_name?, pb_status? } }`
// so the UI can render an "In your collection" pill per row and offer "+ Add"
// for missing ones.
import { NextRequest, NextResponse } from "next/server";
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export const dynamic = "force-dynamic";
export const maxDuration = 180;
const SCRIPTS_DIR = path.join(process.cwd(), "scripts");
// ===================================================================
// WRAPPER SERVICE AUTOSTART
// ===================================================================
// The fast search path is the long-lived wrapper service
// (scripts/_cipc_service.js) on 127.0.0.1:6002, which holds a warm BizPortal
// login. When it is down the route silently degrades to the legacy fallback
// — a cold login on every search, ~70-90s, previously with no timeout at
// all. On 2026-10-09 we found the service had not run since 18 September,
// so CIPC search had been crawling or hanging for three weeks with nothing
// to show why.
//
// So we start it ourselves, lazily, the first time a search comes through.
//
// This lives in the route handler rather than Next's `instrumentation.ts`
// on purpose: the instrumentation bundle is built for the edge runtime,
// where `node:child_process` / `node:net` cannot be resolved. Enabling it
// fails the build and 500s the entire app. Route handlers are compiled for
// the Node runtime, so the builtins resolve correctly.
//
// Guarded by a module-level flag + a port probe, so repeated searches
// never spawn duplicates (including across dev-server hot reloads).
// Set CIPC_AUTOSTART=0 to disable.
const WRAPPER_PORT = 6002;
let autostartAttempted = false;
function portIsOpen(port: number, ms: number): Promise<boolean> {
// Local require rather than the module-level `net` const declared further
// down, so there is no ordering dependency between the two.
const netMod = require("node:net") as typeof import("node:net");
return new Promise((resolve) => {
const socket = netMod.createConnection({ host: "127.0.0.1", port });
const finish = (up: boolean) => {
socket.destroy();
resolve(up);
};
socket.setTimeout(ms);
socket.once("connect", () => finish(true));
socket.once("error", () => finish(false));
socket.once("timeout", () => finish(false));
});
}
async function ensureWrapperService(): Promise<void> {
if (autostartAttempted) return;
autostartAttempted = true;
if (process.env.CIPC_AUTOSTART === "0") return;
try {
// Already running? Leave it alone.
if (await portIsOpen(WRAPPER_PORT, 700)) return;
const scriptPath = path.join(SCRIPTS_DIR, "_cipc_service.js");
if (!fs.existsSync(scriptPath)) {
console.warn("[cipc] _cipc_service.js not found — skipping autostart");
return;
}
// Detached, but output still captured — the missing log is what made
// the three-week outage invisible in the first place.
const logFd = fs.openSync(path.join(process.cwd(), "_cipc_service.log"), "a");
const child = spawn(process.execPath, [scriptPath], {
detached: true,
stdio: ["ignore", logFd, logFd],
windowsHide: true,
cwd: process.cwd(),
});
child.unref();
console.log(`[cipc] wrapper service autostarted on ${WRAPPER_PORT} — pid ${child.pid}`);
} catch (err) {
// Never block the search; the legacy fallback still works.
console.warn("[cipc] wrapper autostart failed:", err);
}
}
const SCRIPTS: Record<string, string> = {
enterprise_no: "Bizportal Enterprise No. Search.py",
director_id_no: "Bizportal Directopr id no search.py",
};
// Recognise any of:
// K/M/B + 10 digits (modern prefix)
// 10-12 digits on their own (some CIPC numbers are 8-digit numeric + suffix)
// "2022/808158/08" — the legacy close-corporation / old-style number
const ENTERPRISE_NO_PATTERNS: RegExp[] = [
/^[KMB]\d{10,12}$/i,
/^\d{10,12}$/,
/^\d{4}\/\d{6,7}\/\d{2}$/,
];
function isEnterpriseNo(s: string): boolean {
return ENTERPRISE_NO_PATTERNS.some((re) => re.test(s.trim()));
}
/**
* Generate every sensible text form of an Enterprise Number we might find in
* PocketBase. PB usually stores the canonical K-prefixed form, but legacy
* records can be slash-formatted (2022/808158/08) or digit-only. Used by the
* collection_matches enrichment to broaden the PB filter and avoid misses.
*/
function normalizeEnterpriseNoForms(raw: string): string[] {
const t = raw.trim().toUpperCase();
const digits = t.replace(/^([KMB])|[\s/]/g, "");
const out = new Set<string>();
if (/^[KMB]/.test(t)) out.add(t);
out.add(digits);
if (digits.length >= 8) out.add(`K${digits}`);
const slashMatch = t.match(/^(\d{4})\/(\d{6,7})(?:\/\d{2})?$/);
if (slashMatch) {
out.add(`K${slashMatch[1]}${slashMatch[2]}`);
}
out.add(t);
return Array.from(out);
}
/** Read PB_TOKEN from Workflow/.env.local. */
function readPbToken(): string {
const fs = require("node:fs") as typeof import("node:fs");
const envPath = path.join(process.cwd(), ".env.local");
try {
const raw = fs.readFileSync(envPath, "utf-8");
for (const line of raw.split(/\r?\n/)) {
if (line.startsWith("PB_TOKEN=")) {
return line.slice("PB_TOKEN=".length).trim();
}
}
} catch {}
return "";
}
/** Hit PocketBase to resolve a K-number to a company name. Returns null on miss. */
async function resolveEnterpriseNoToName(kNo: string): Promise<string | null> {
const token = readPbToken();
if (!token) return null;
const unique = normalizeEnterpriseNoForms(kNo);
for (const candidate of unique) {
const filter = `enterprise_no="${candidate.replace(/"/g, '\\"')}"`;
const url = `https://pb.agent.apphub.co.za/api/collections/ysterbaard_companies/records?perPage=1&filter=${encodeURIComponent(filter)}`;
try {
const r = await fetch(url, {
headers: { Authorization: token },
signal: AbortSignal.timeout(8000),
});
if (!r.ok) continue;
const j = await r.json();
const item = j?.items?.[0];
if (item?.enterprise_name) return item.enterprise_name;
} catch {
// ignore — fall through to next candidate or null
}
}
return null;
}
// ===================================================================
// WRAPPER-SERVICE CALL (Architecture A6)
// ===================================================================
//
// If scripts/_cipc_service.js is running on 127.0.0.1:6002, prefer it.
// The wrapper owns the agent-browser session and watches it via a 30 s
// tick; calls are serialised through a queue so login + search can't
// race. When the wrapper is offline (connection refused) we transparently
// fall back to the legacy `spawnSync` path below — full backwards compat.
// ===================================================================
const net = require("node:net") as typeof import("node:net");
/** How long the legacy cold-login path may run before we give up. */
const LEGACY_SPAWN_TIMEOUT_MS = 180_000;
interface WrapperResponse {
ok: boolean;
/** Populated on success; full .py stdout. */
stdout?: string;
/** Populated on non-zero exit. */
exitCode?: number;
duration_ms?: number;
warmed_up?: boolean;
stderr?: string;
error?: string;
}
function callWrapper(
kind: string,
query: string,
timeoutMs: number
): Promise<WrapperResponse> {
return new Promise((resolve) => {
let resolved = false;
let buf = "";
let socket: ReturnType<typeof net.createConnection> | null = null;
const start = Date.now();
function done(res: WrapperResponse) {
if (resolved) return;
resolved = true;
try { socket?.end(); } catch {}
resolve(res);
}
const timer = setTimeout(() => {
done({ ok: false, error: `wrapper timeout (${timeoutMs}ms)`, duration_ms: Date.now() - start });
}, timeoutMs);
try {
socket = net.createConnection({ host: "127.0.0.1", port: 6002 });
} catch (e: any) {
clearTimeout(timer);
done({ ok: false, error: `wrapper connect failed: ${e?.message || String(e)}` });
return;
}
socket.setTimeout(timeoutMs);
socket.on("connect", () => {
const line =
JSON.stringify({ kind, query }) + "\n";
socket!.write(line);
});
socket.on("data", (chunk) => {
buf += chunk.toString("utf-8");
const idx = buf.indexOf("\n");
if (idx >= 0) {
const line = buf.slice(0, idx);
clearTimeout(timer);
try {
done({ ...JSON.parse(line), duration_ms: Date.now() - start });
} catch (e: any) {
done({ ok: false, error: `wrapper response invalid json: ${e?.message || String(e)}`, duration_ms: Date.now() - start });
}
}
});
socket.on("error", (e: any) => {
clearTimeout(timer);
done({ ok: false, error: `wrapper socket error: ${e?.message || String(e)}` });
});
socket.on("close", () => {
clearTimeout(timer);
if (!resolved) {
done({ ok: false, error: "wrapper closed without response" });
}
});
});
}
interface CollectionMatch {
in_collection: boolean;
pb_id?: string;
pb_name?: string;
pb_status?: string;
}
/**
* For every Enterprise Number mentioned in the script's output, ask PocketBase
* whether a record already exists. Returns a map keyed by the canonical
* (K-prefixed) form the script emitted — that's what the UI uses to match
* rows in the results table. Per-entNo normal forms are unioned into a single
* OR filter so PB is hit at most once per request regardless of result count.
*/
async function collectCollectionMatches(
entNos: string[],
token: string,
): Promise<Record<string, CollectionMatch>> {
const out: Record<string, CollectionMatch> = {};
if (entNos.length === 0 || !token) return out;
// Build a deduped pool of every possible PB-side text form.
const candidates = new Set<string>();
const canonicalByForm = new Map<string, string>(); // form -> original ent_no we want to key by
for (const e of entNos) {
const forms = normalizeEnterpriseNoForms(e);
for (const f of forms) canonicalByForm.set(f, e.toUpperCase());
for (const f of forms) candidates.add(f);
}
if (candidates.size === 0) return out;
const filter = Array.from(candidates)
.map((c) => `enterprise_no="${c.replace(/"/g, '\\"')}"`)
.join(" || ");
const url =
`https://pb.agent.apphub.co.za/api/collections/ysterbaard_companies/records` +
`?perPage=500&filter=${encodeURIComponent(filter)}`;
try {
const r = await fetch(url, {
headers: { Authorization: token },
signal: AbortSignal.timeout(8000),
});
if (!r.ok) return out;
const j = await r.json();
const items: any[] = j?.items ?? [];
for (const item of items) {
const originalKey = canonicalByForm.get(item.enterprise_no);
if (!originalKey) continue;
out[originalKey] = {
in_collection: true,
pb_id: item.id,
pb_name: item.enterprise_name || "",
pb_status: item.enterprise_status || "",
};
}
// Mark every requested ent_no as not-in-collection if it isn't in `out`.
for (const e of entNos) {
const k = e.toUpperCase();
if (!out[k]) out[k] = { in_collection: false };
}
} catch {
// PB unreachable — leave entNos un-enriched; UI will show "unknown"
for (const e of entNos) {
out[e.toUpperCase()] = { in_collection: false };
}
}
return out;
}
/**
* Pull every Enterprise Number mentioned in the .py's markdown output so we
* can ask PB about each one.
*
* For "director_id_no" — each data row's first cell is an Enterprise Number.
* For "enterprise_no" — the canonical K-number lives in the title in parens
* ("## Company: ... (K2018435959)"). The body sections also repeat it but
* we only need it once.
*/
function extractEnterpriseNos(
md: string,
kind: "enterprise_no" | "director_id_no",
): string[] {
if (kind === "director_id_no") {
const seen = new Set<string>();
for (const m of md.matchAll(/^\|\s*([KMB]\d{10,12})\s*\|/gim)) {
seen.add(m[1].toUpperCase());
}
return Array.from(seen);
}
const titleMatch = md.match(/\(([KMB]\d{10,12})\)/);
return titleMatch ? [titleMatch[1].toUpperCase()] : [];
}
// ===================================================================
// CIPC FIELD EXTRACTION (Enterprise No. search only)
// ===================================================================
interface CipcFields {
/** Flat key/value pairs from the "### ENTERPRISE DETAILS" section. Keys
* are the BizPortal labels as printed ("Enterprise Type", "Status",
* "Registration Date", etc.). The UI maps these onto PB fields. */
kv: Record<string, string>;
/** Director rows from the "### DIRECTOR DETAILS" section table. */
directors: Array<{ name: string; id: string }>;
}
/**
* Extract the structured data we want from the .py's Enterprise No. markdown.
*
* The `.py` output for Enterprise No. has these sections (frozen script):
* ### COMPANY DETAILS — KV lines we map onto PB fields.
* ### DIRECTOR DETAILS — table with columns
* [ID Number | Name(s) | Surname | Type | Status].
* ### FILED ANNUAL RETURNS — ignore (no PB equivalent field).
* ### OUTSTANDING ANNUAL RETURNS — ignore.
* ### OTHER DETAILS — partial overlap (SARS tax number etc.),
* not surfaced in the diff yet — the diff
* only handles fields CIPC labels as
* "CIPC Physical address" / "CIPC Postal
* address" and the canonical KV entries.
*/
function extractCipcFields(md: string): CipcFields {
const out: CipcFields = { kv: {}, directors: [] };
// COMPANY DETAILS — KV lines like "- Enterprise Type: Private Company".
// CIPC labels use colons with potentially long keys including "CIPC".
const ent = md.match(
/### COMPANY DETAILS\s*\n([\s\S]*?)(?=\n### |\n*$)/i
);
if (ent) {
for (const line of ent[1].split(/\r?\n/)) {
const m = line.match(/^\s*-\s*([^:]+?):\s*(.+?)\s*$/);
if (m) out.kv[m[1].trim()] = m[2].trim();
}
}
// DIRECTOR DETAILS — 5-column table; collapse Name + Surname into the
// single "name" field so the UI gets a clean "Name (ID)" display.
const dir = md.match(
/### DIRECTOR DETAILS\s*\n([\s\S]*?)(?=\n### |\n*$)/i
);
if (dir) {
let seenHeader = false;
for (const line of dir[1].split(/\r?\n/)) {
const t = line.trim();
if (!t.startsWith("|")) continue;
const cells = t.split("|").slice(1, -1).map((c) => c.trim());
// Alignment row "|----|----|"
if (cells.every((c) => /^[-:]+$/.test(c))) continue;
if (!seenHeader) {
seenHeader = true;
continue;
}
// Layout: [ID Number | Name(s) | Surname | Type | Status]
const id = cells[0] ?? "";
if (!id) continue;
const first = cells[1] ?? "";
const last = cells[2] ?? "";
const fullName = [first, last].filter(Boolean).join(" ");
out.directors.push({ name: fullName, id });
}
}
return out;
}
/** Fetch the full PB record for a given Enterprise Number. Returns null
* when not found. Uses the same normalisation helper as the rest of the
* route (K/M/B prefix, digits-only, slash-form legacy). */
async function fetchFullPbRecord(
kNo: string,
token: string,
): Promise<Record<string, any> | null> {
if (!token) return null;
for (const f of normalizeEnterpriseNoForms(kNo)) {
const filter = `enterprise_no="${f.replace(/"/g, '\\"')}"`;
const url =
`https://pb.agent.apphub.co.za/api/collections/ysterbaard_companies/records` +
`?perPage=1&filter=${encodeURIComponent(filter)}`;
try {
const r = await fetch(url, {
headers: { Authorization: token },
signal: AbortSignal.timeout(8000),
});
if (!r.ok) continue;
const j = await r.json();
const item = j?.items?.[0];
if (item?.id) return item;
} catch {}
}
return null;
}
function badRequest(message: string) {
return NextResponse.json({ ok: false, error: message }, { status: 400 });
}
/** Extract the markdown block from the full stdout. */
function extractMarkdown(stdout: string): string {
const idx = stdout.indexOf("\n#");
if (idx === -1) {
const hashIdx = stdout.indexOf("# ");
if (hashIdx === -1) return "";
return stdout.slice(hashIdx).trim();
}
return stdout.slice(idx + 1).trim();
}
function extractCounter(stdout: string): number | undefined {
const m = stdout.match(/Searches Conducted Today:\s*(\d+)/);
return m ? Number(m[1]) : undefined;
}
function extractError(stdout: string): string {
const lines = stdout.split("\n");
const errors = lines.filter((l) => l.trim().startsWith("ERROR:") || l.includes("STOP:"));
if (errors.length > 0) return errors.map((l) => l.trim()).join(" · ");
const lastMeaningful = lines.filter((l) => l.trim()).slice(-3).join(" | ");
return lastMeaningful || "Unknown error";
}
export async function POST(req: NextRequest) {
// Make sure the fast path exists before we decide which path to take.
// Fire-and-forget: never delays this request.
void ensureWrapperService();
let body: { kind?: string; query?: string };
try {
body = await req.json();
} catch {
return badRequest("Invalid JSON body");
}
const kind = body.kind;
const rawQuery = (body.query || "").trim();
if (kind !== "enterprise_no" && kind !== "director_id_no") {
return badRequest("kind must be 'enterprise_no' or 'director_id_no'");
}
if (!rawQuery) return badRequest("query is required");
let resolvedQuery = rawQuery;
let resolvedVia: "k_number" | "company_name" | "director_id" | null = null;
// Enterprise No. search: if input looks like a K-number, try to resolve to a
// company name via PocketBase. If PB doesn't know it, forward the raw K to
// the script anyway (the .py will fail clearly with "no PB record matched"
// — which is the correct error for an unknown K).
if (kind === "enterprise_no" && isEnterpriseNo(rawQuery)) {
const name = await resolveEnterpriseNoToName(rawQuery);
if (name) {
resolvedQuery = name;
resolvedVia = "k_number";
} else {
resolvedVia = null; // forward raw; .py will report its own PB error
}
} else if (kind === "enterprise_no") {
resolvedVia = "company_name";
} else if (kind === "director_id_no") {
resolvedVia = "director_id";
}
const scriptName = SCRIPTS[kind];
const scriptPath = path.join(SCRIPTS_DIR, scriptName);
const venvPython = path.join(
SCRIPTS_DIR,
".venv",
"Scripts",
process.platform === "win32" ? "python.exe" : "python"
);
const start = Date.now();
// WRAPPER-FIRST: try the long-lived _cipc_service.js on 127.0.0.1:6002.
// On connect-refused / socket error / wrapper timeout, fall through to the
// legacy `spawnSync` path so the route stays functional even if the
// service is offline.
const wrapped = await callWrapper(kind, resolvedQuery, 165_000);
let duration: number;
let stdout: string;
let stderr: string;
let exitCode: number | null;
let wrapperUsed: boolean;
if (wrapped.ok) {
duration = wrapped.duration_ms ?? Date.now() - start;
stdout = wrapped.stdout || "";
stderr = wrapped.stderr || "";
exitCode = wrapped.exitCode ?? 0;
wrapperUsed = true;
} else if (
// Treat "service offline" errors as a fall-back trigger. The wrapper's
// own error message contains "connect" or "closed without response"
// or "socket error" — we look for those. Anything else is a real
// failure (e.g. BizPortal subscription expired mid-search).
/connect|ECONNREFUSED|closed without response|wrapper socket error/i.test(
wrapped.error || ""
)
) {
// Fall through to legacy spawnSync below.
const quoted = [
`"${venvPython.replace(/"/g, '\\"')}"`,
`"${scriptPath.replace(/"/g, '\\"')}"`,
`"${resolvedQuery.replace(/"/g, '\\"')}"`,
].join(" ");
let result: ReturnType<typeof spawnSync>;
try {
result = spawnSync(quoted, {
cwd: SCRIPTS_DIR,
encoding: "utf-8",
maxBuffer: 32 * 1024 * 1024,
shell: true,
windowsHide: true,
// A cold search legitimately takes 70-90s (full BizPortal login plus
// the 35s result-pane wait). Past LEGACY_SPAWN_TIMEOUT_MS something
// is wedged — each agent-browser step has its own 60s cap, so a
// broken page stacks several of them. Kill it and say so instead of
// blocking the request forever. Without this, a hung search left the
// UI spinning indefinitely (seen 2026-10-09).
timeout: LEGACY_SPAWN_TIMEOUT_MS,
killSignal: "SIGKILL",
});
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: `Failed to spawn ${scriptName}: ${err?.message || String(err)}` },
{ status: 500 }
);
}
// spawnSync reports a timeout via `.error` (ETIMEDOUT), not a non-zero
// exit code, so this needs handling separately.
if (result.error && (result.error as any).code === "ETIMEDOUT") {
duration = Date.now() - start;
return NextResponse.json(
{
ok: false,
error:
`The CIPC search service is not responding — the lookup was stopped after ${Math.round(
LEGACY_SPAWN_TIMEOUT_MS / 1000
)}s. ` +
"This is the slow fallback path doing a cold login; it usually means the CIPC wrapper " +
"service on port 6002 is down. Restart the app (it autostarts now) and try again.",
duration_ms: duration,
search_kind: kind,
query: rawQuery,
resolved_query: resolvedQuery,
resolved_via: resolvedVia,
timed_out: true,
used_slow_path: true,
},
{ status: 504 }
);
}
duration = Date.now() - start;
// `result` is typed as the un-overloaded spawnSync return, so stdout/stderr
// are `string | Buffer`. encoding: "utf-8" is set above, but toString()
// is a no-op on a string and the correct decode if that ever changes.
stdout = result.stdout?.toString() ?? "";
stderr = result.stderr?.toString() ?? "";
exitCode = result.status;
wrapperUsed = false;
} else {
// Wrapper was reachable but rejected the request (real failure).
return NextResponse.json(
{
ok: false,
error: wrapped.error || "wrapper error",
raw: (wrapped.stdout || "") + (wrapped.stderr ? `\n\n--- stderr ---\n${wrapped.stderr}` : ""),
duration_ms: wrapped.duration_ms ?? Date.now() - start,
search_kind: kind,
query: rawQuery,
resolved_query: resolvedQuery,
resolved_via: resolvedVia,
...(wrapped.warmed_up !== undefined ? { warmed_up: wrapped.warmed_up } : {}),
},
{ status: 500 }
);
}
// If we got here via legacy spawnSync, exitCode holds result.status (number).
// If we got here via wrapper, exitCode holds exitCode from python (.py).
if (exitCode !== 0) {
return NextResponse.json(
{
ok: false,
error: extractError(stdout) || `${scriptName} exited with code ${exitCode}`,
raw: stdout + (stderr ? `\n\n--- stderr ---\n${stderr}` : ""),
duration_ms: duration,
search_kind: kind,
query: rawQuery,
resolved_query: resolvedQuery,
resolved_via: resolvedVia,
wrapper_used: wrapperUsed,
},
{ status: 500 }
);
}
const markdown = extractMarkdown(stdout);
const counter = extractCounter(stdout);
// Enrichment: ask PB whether each returned Enterprise Number already exists
// in `ysterbaard_companies`. The .py scripts emit the canonical K/M/B form
// so this matches cleanly most of the time; the OR filter also covers
// legacy slash formats that PB may store.
const entNos = extractEnterpriseNos(markdown, kind);
const pbToken = readPbToken();
const collection_matches = await collectCollectionMatches(entNos, pbToken);
// For Enterprise No. searches, also extract the structured CIPC body
// (ENTERPRISE DETAILS + DIRECTOR DETAILS) and fetch the full PB record if
// the company exists. The UI uses both to render a field-by-field diff
// with one-click PATCHes.
let pb_record: Record<string, any> | null = null;
let cipc_fields: CipcFields | null = null;
let cipc_directors: Array<{ name: string; id: string }> | null = null;
if (kind === "enterprise_no") {
cipc_fields = extractCipcFields(markdown);
cipc_directors = cipc_fields.directors;
const kNo = entNos[0];
const matched = kNo ? collection_matches[kNo.toUpperCase()] : null;
if (matched?.in_collection && matched.pb_id) {
pb_record = await fetchFullPbRecord(kNo, pbToken);
}
}
return NextResponse.json({
ok: true,
markdown,
raw: stdout,
duration_ms: duration,
counter,
search_kind: kind,
query: rawQuery,
resolved_query: resolvedQuery,
resolved_via: resolvedVia,
collection_matches,
wrapper_used: wrapperUsed,
...(kind === "enterprise_no"
? {
pb_record,
cipc_fields: cipc_fields?.kv ?? {},
cipc_directors,
}
: {}),
});
}