diff --git a/.gitignore b/.gitignore index 8ec481d..e9b7492 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ dist/ # Next.js type helpers next-env.d.ts +tsconfig.tsbuildinfo # Editor cruft .DS_Store diff --git a/app/api/bizportal-2/health/route.ts b/app/api/bizportal-2/health/route.ts new file mode 100644 index 0000000..1a952ff --- /dev/null +++ b/app/api/bizportal-2/health/route.ts @@ -0,0 +1,22 @@ +// app/api/bizportal-2/health/route.ts +// GET /api/bizportal-2/health — proxy to the Python service. + +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const SERVICE_URL = process.env.BIZPORTAL_SERVICE_URL ?? "http://127.0.0.1:7001"; + +export async function GET() { + try { + const res = await fetch(`${SERVICE_URL}/health`, { cache: "no-store" }); + const data = await res.json().catch(() => ({ ok: false })); + return NextResponse.json(data, { status: res.ok ? 200 : 503 }); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: `Python service not reachable at ${SERVICE_URL}: ${err.message ?? err}` }, + { status: 503 }, + ); + } +} diff --git a/app/api/bizportal-2/reserve/route.ts b/app/api/bizportal-2/reserve/route.ts new file mode 100644 index 0000000..5b2fe69 --- /dev/null +++ b/app/api/bizportal-2/reserve/route.ts @@ -0,0 +1,53 @@ +// app/api/bizportal-2/reserve/route.ts +// POST /api/bizportal-2/reserve +// Reserves a name on BizPortal via the Python sidecar. Replaces +// the agent-driven /api/register/[id]/apply-bizportal route for +// test purposes. The old route is untouched. + +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 180; + +const SERVICE_URL = process.env.BIZPORTAL_SERVICE_URL ?? "http://127.0.0.1:7001"; + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const names: string[] = Array.isArray(body?.names) ? body.names : []; + const dryRun: boolean = !!body?.dry_run; + const balanceMin: number | undefined = typeof body?.balance_min === "number" ? body.balance_min : undefined; + + if (names.length === 0) { + return NextResponse.json({ ok: false, error: "At least one name is required" }, { status: 400 }); + } + + try { + const res = await fetch(`${SERVICE_URL}/reserve`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ names, dry_run: dryRun, balance_min: balanceMin }), + signal: req.signal, + }); + + const text = await res.text(); + let data: any; + try { + data = JSON.parse(text); + } catch { + return NextResponse.json( + { ok: false, error: `Python service returned non-JSON: ${text.slice(0, 200)}` }, + { status: 502 }, + ); + } + + return NextResponse.json(data, { status: res.ok ? 200 : res.status }); + } catch (err: any) { + if (err?.name === "AbortError") { + return NextResponse.json({ ok: false, error: "Reservation cancelled" }, { status: 499 }); + } + return NextResponse.json( + { ok: false, error: `Cannot reach Python service at ${SERVICE_URL}: ${err.message ?? err}` }, + { status: 503 }, + ); + } +} diff --git a/app/api/bizportal-2/search/route.ts b/app/api/bizportal-2/search/route.ts new file mode 100644 index 0000000..65b1321 --- /dev/null +++ b/app/api/bizportal-2/search/route.ts @@ -0,0 +1,64 @@ +// app/api/bizportal-2/search/route.ts +// POST /api/bizportal-2/search +// Thin wrapper around the Python Playwright sidecar. Synchronous +// — no queue, no polling. The client gets the result in one round +// trip (~5s typical, ~30s worst case for cold login). + +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 120; + +const SERVICE_URL = process.env.BIZPORTAL_SERVICE_URL ?? "http://127.0.0.1:7001"; + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const { type, query } = body ?? {}; + + if (!type || !["id", "ck"].includes(type)) { + return NextResponse.json({ ok: false, error: "type must be 'id' or 'ck'" }, { status: 400 }); + } + if (!query?.trim()) { + return NextResponse.json({ ok: false, error: "query is required" }, { status: 400 }); + } + + const start = Date.now(); + + try { + const res = await fetch(`${SERVICE_URL}/search`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, query: query.trim() }), + // Signal upstream so the browser-side AbortController can cancel. + signal: req.signal, + }); + + const text = await res.text(); + let data: any; + try { + data = JSON.parse(text); + } catch { + return NextResponse.json( + { ok: false, error: `Python service returned non-JSON: ${text.slice(0, 200)}` }, + { status: 502 }, + ); + } + + if (!res.ok && data.ok === false) { + return NextResponse.json(data, { status: res.status }); + } + + return NextResponse.json({ + ...data, + duration_ms: Date.now() - start, + }); + } catch (err: any) { + if (err?.name === "AbortError") { + return NextResponse.json({ ok: false, error: "Search cancelled" }, { status: 499 }); + } + return NextResponse.json( + { ok: false, error: `Cannot reach Python service at ${SERVICE_URL}: ${err.message ?? err}` }, + { status: 503 }, + ); + } +} diff --git a/app/bizportal-2/BizPortal2Explorer.tsx b/app/bizportal-2/BizPortal2Explorer.tsx new file mode 100644 index 0000000..e8ee7f9 --- /dev/null +++ b/app/bizportal-2/BizPortal2Explorer.tsx @@ -0,0 +1,700 @@ +// app/bizportal-2/BizPortal2Explorer.tsx +// Client component — same UX as the existing /bizportal explorer +// but fetches synchronously from the Python sidecar. No polling, +// no task queue, no LLM agent. Times the round-trip so the user +// can see the speed difference. + +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; + +type SearchType = "id" | "ck"; + +interface SearchResponse { + ok: boolean; + results?: any[]; + error?: string; + log?: string[]; + query?: string; + type?: string; + duration_ms?: number; +} + +export default function BizPortal2Explorer() { + const searchParams = useSearchParams(); + const initialType = (searchParams.get("type") as SearchType) === "ck" ? "ck" : "id"; + const initialQuery = searchParams.get("q") || ""; + + const [activeTab, setActiveTab] = useState(initialType); + const [query, setQuery] = useState(initialQuery); + const [loading, setLoading] = useState(false); + const [serviceUp, setServiceUp] = useState(null); + const [data, setData] = useState(null); + + const abortRef = useRef(null); + + // Health check on mount so the user can see immediately if the + // Python sidecar is running. + useEffect(() => { + fetch("/api/bizportal-2/health") + .then((r) => r.json()) + .then((d) => setServiceUp(!!d.ok && !!d.playwright_ready)) + .catch(() => setServiceUp(false)); + }, []); + + // Auto-fire search when arriving from a director/company deep-link. + const autoFiredRef = useRef(false); + useEffect(() => { + if (initialQuery && !autoFiredRef.current) { + autoFiredRef.current = true; + setTimeout(() => { + const form = document.getElementById("bizportal-2-search-form") as HTMLFormElement | null; + if (form) form.requestSubmit(); + }, 100); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handleSearch(e: React.FormEvent) { + e.preventDefault(); + if (!query.trim()) return; + + setLoading(true); + setData(null); + + const controller = new AbortController(); + abortRef.current = controller; + + try { + const res = await fetch("/api/bizportal-2/search", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: activeTab, query: query.trim() }), + signal: controller.signal, + }); + const json: SearchResponse = await res.json(); + setData(json); + } catch (err: any) { + if (err.name !== "AbortError") { + setData({ ok: false, error: err.message || "Network error" }); + } + } finally { + setLoading(false); + } + } + + function cancelSearch() { + abortRef.current?.abort(); + setLoading(false); + } + + return ( +
+ + + {/* ── Tab switcher ──────────────────────────────────────────────── */} +
+ {([["id", "Director ID No."], ["ck", "CK / Enterprise No."]] as [SearchType, string][]).map(([val, label]) => ( + + ))} +
+ + {/* ── Search form ──────────────────────────────────────────────── */} +
+
+ setQuery(e.target.value)} + className="flex-1 px-4 py-3 rounded-md border border-border bg-surface text-fg text-sm placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent" + required + /> + + {loading && ( + + )} +
+
+ + {/* ── Status / Result ──────────────────────────────────────────── */} + {data && ( +
+ {!data.ok ? ( +
+

Search failed

+

{data.error}

+ {data.log && data.log.length > 0 && } +
+ ) : data.results && data.results.length > 0 ? ( + + ) : ( +
+

No results found.

+ {data.log && data.log.length > 0 && } +
+ )} +
+ )} +
+ ); +} + +function ServiceStatusBanner({ up }: { up: boolean | null }) { + if (up === null) { + return ( +
+ Checking Python service… +
+ ); + } + if (up) { + return ( +
+ ✅ Python sidecar is up on 127.0.0.1:7001 — search will be synchronous. +
+ ); + } + return ( +
+

Python sidecar is not reachable.

+

+ Start it with:{" "} + + cd scripts && source .venv/bin/activate && uvicorn bizportal_service:app --port 7001 + +

+

+ See scripts/README.md for the first-time setup. +

+
+ ); +} + +function LogBlock({ log }: { log: string[] }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
+          {log.join("\n")}
+        
+ )} +
+ ); +} + +// ── Result display ─────────────────────────────────────────────────────── + +interface DoneViewProps { + results: any[]; + activeTab: SearchType; + query: string; + durationMs?: number; + log?: string[]; +} + +function DoneView({ results, activeTab, query, durationMs, log }: DoneViewProps) { + const [dbCompanies, setDbCompanies] = useState>(new Map()); + const [dbDirectors, setDbDirectors] = useState>(new Map()); + const [links, setLinks] = useState>(new Set()); + const [loaded, setLoaded] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [bulkProcessing, setBulkProcessing] = useState(false); + const [bulkMsg, setBulkMsg] = useState(""); + + function maskId(id: string | undefined): string { + if (!id) return ""; + const digits = String(id).replace(/\s/g, ""); + if (/X/i.test(digits)) return digits; + if (digits.length === 13) return digits.slice(0, 6) + "XXXX" + digits.slice(10, 12) + "X"; + return digits; + } + + useEffect(() => { + if (results.length === 0) return; + async function load() { + try { + const [compRes, dirRes] = await Promise.all([ + fetch("/api/pb/records/list?collection=ysterbaard_companies&perPage=5000"), + fetch("/api/pb/records/list?collection=ysterbaard_directors&perPage=5000"), + ]); + const compData = await compRes.json(); + const dirData = await dirRes.json(); + const cmap = new Map(); + (compData.items || []).forEach((c: any) => { + if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c); + }); + const dmap = new Map(); + (dirData.items || []).forEach((d: any) => { + if (d.id_no_masked) dmap.set(d.id_no_masked, d); + if (d.id_no) dmap.set(d.id_no, d); + }); + setDbCompanies(cmap); + setDbDirectors(dmap); + + const linkSet = new Set(); + for (const c of results) { + const compEntNo = String(c.enterprise_no || "").replace(/\s/g, ""); + const compRecord = cmap.get(compEntNo); + if (!compRecord) continue; + for (const d of (c.directors || [])) { + const masked = maskId(d.id_number); + const dirRecord = dmap.get(masked) || dmap.get(d.id_number); + if (dirRecord) { + try { + const jres = await fetch(`/api/pb/records/list?collection=ysterbaard_company_directors&perPage=500&filter=${encodeURIComponent(`director='${dirRecord.id}'`)}`); + const jdata = await jres.json(); + for (const j of (jdata.items || [])) { + if (j.company === compRecord.id) { + linkSet.add(`${compRecord.id}-${dirRecord.id}`); + } + } + } catch {} + } + } + } + setLinks(linkSet); + } finally { + setLoaded(true); + } + } + load(); + }, [results]); + + function getCompanyRecord(c: any) { + return dbCompanies.get(String(c.enterprise_no || "").replace(/\s/g, "")); + } + function getDirectorRecord(d: any) { + return dbDirectors.get(maskId(d.id_number)) || dbDirectors.get(d.id_number); + } + function isLinked(compRec: any, dirRec: any) { + return compRec && dirRec && links.has(`${compRec.id}-${dirRec.id}`); + } + function toggle(key: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); else next.add(key); + return next; + }); + } + function selectAllMissing() { + const next = new Set(); + for (const c of results) { + if (!getCompanyRecord(c)) next.add(`company:${c.enterprise_no}`); + for (const d of (c.directors || [])) { + if (!getDirectorRecord(d)) next.add(`director:${maskId(d.id_number)}`); + } + } + setSelected(next); + } + function clearSelection() { setSelected(new Set()); } + + async function processSelected() { + if (selected.size === 0) return; + setBulkProcessing(true); + setBulkMsg(""); + try { + const items: any[] = []; + for (const c of results) { + if (selected.has(`company:${c.enterprise_no}`)) { + items.push({ + collection: "ysterbaard_companies", + fields: { + enterprise_no: c.enterprise_no || "", + enterprise_name: c.enterprise_name || "", + enterprise_type: c.enterprise_type || "", + source: "bizportal-2", + }, + }); + } + } + for (const c of results) { + for (const d of (c.directors || [])) { + const masked = maskId(d.id_number); + if (selected.has(`director:${masked}`)) { + items.push({ + collection: "ysterbaard_directors", + fields: { + id_no: masked.replace(/X/gi, "0"), + surname: d.surname || "", + first_names: d.names || "", + director_type: (d.type || "Director").toLowerCase(), + source: "bizportal-2", + }, + }); + } + } + } + const res = await fetch("/api/pb/records/bulk-create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items }), + }); + const data = await res.json(); + + const [compRes2, dirRes2] = await Promise.all([ + fetch("/api/pb/records/list?collection=ysterbaard_companies&perPage=5000"), + fetch("/api/pb/records/list?collection=ysterbaard_directors&perPage=5000"), + ]); + const compData2 = await compRes2.json(); + const dirData2 = await dirRes2.json(); + const cmap = new Map(); + (compData2.items || []).forEach((c: any) => { + if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c); + }); + const dmap = new Map(); + (dirData2.items || []).forEach((d: any) => { + if (d.id_no_masked) dmap.set(d.id_no_masked, d); + if (d.id_no) dmap.set(d.id_no, d); + }); + setDbCompanies(cmap); + setDbDirectors(dmap); + + const linkItems: any[] = []; + for (const c of results) { + const compRec = cmap.get(String(c.enterprise_no || "").replace(/\s/g, "")); + if (!compRec) continue; + for (const d of (c.directors || [])) { + const masked = maskId(d.id_number); + const dirRec = dmap.get(masked); + if (!dirRec) continue; + if (links.has(`${compRec.id}-${dirRec.id}`)) continue; + linkItems.push({ company: compRec.id, director: dirRec.id, director_type: d.type || "Director" }); + } + } + + let linkSummary = ""; + if (linkItems.length > 0) { + const lres = await fetch("/api/pb/records/bulk-link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: linkItems }), + }); + const ldata = await lres.json(); + const nextLinks = new Set(links); + for (const li of linkItems) nextLinks.add(`${li.company}-${li.director}`); + setLinks(nextLinks); + linkSummary = `, linked ${ldata.linked_count} (${ldata.already_count} already)`; + } + + const failMsg = data.fail_count > 0 ? `, ${data.fail_count} failed` : ""; + setBulkMsg(`✅ Added ${data.success_count}${failMsg}${linkSummary}`); + setSelected(new Set()); + } catch (err: any) { + setBulkMsg(`❌ Error: ${err.message}`); + } finally { + setBulkProcessing(false); + } + } + + async function linkOne(compRec: any, dirRec: any, d: any) { + const res = await fetch("/api/pb/records/link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ company: compRec.id, director: dirRec.id, director_type: d.type || "Director" }), + }); + if (res.ok) { + setLinks((prev) => new Set(prev).add(`${compRec.id}-${dirRec.id}`)); + setBulkMsg("✅ Linked"); + } else { + const data = await res.json().catch(() => ({})); + setBulkMsg(`❌ Link failed: ${data.error || res.status}`); + } + } + + const selectedCount = selected.size; + const missingCompanies = results.filter((c) => !getCompanyRecord(c)).length; + const missingDirectors = results.flatMap((c) => c.directors || []).filter((d) => !getDirectorRecord(d)).length; + const linkableCount = results.reduce((acc, c) => { + const compRec = getCompanyRecord(c); + if (!compRec) return acc; + return acc + (c.directors || []).filter((d: any) => { + const dirRec = getDirectorRecord(d); + return dirRec && !links.has(`${compRec.id}-${dirRec.id}`); + }).length; + }, 0); + + return ( +
+
+
+ {loaded ? ( + <> + {results.length} result{results.length === 1 ? "" : "s"} ·{" "} + 0 ? "text-yellow-700" : "text-green-700"}> + {results.length - missingCompanies}/{results.length} companies in DB + + {" · "} + 0 ? "text-yellow-700" : "text-green-700"}> + {missingDirectors} missing director{missingDirectors === 1 ? "" : "s"} + + {linkableCount > 0 && ( + <> + {" · "} + {linkableCount} linkable pair{linkableCount === 1 ? "" : "s"} + + )} + + ) : ( + "Loading DB state…" + )} + {durationMs !== undefined && ( + · {durationMs}ms round-trip + )} +
+
+ {selectedCount > 0 ? ( + <> + {selectedCount} selected + + + + ) : ( + + )} +
+
+ + {bulkMsg && ( +
{bulkMsg}
+ )} + + {log && log.length > 0 && } + +
+ {results.map((c, idx) => ( + + ))} +
+
+ ); +} + +function CompanyResult({ + company, + maskId, + dbCompany, + getDirectorRecord, + isLinked, + links, + selected, + toggle, + onLink, +}: { + company: any; + maskId: (s: string) => string; + dbCompany: any; + getDirectorRecord: (d: any) => any; + isLinked: (compRec: any, dirRec: any) => boolean; + links: Set; + selected: Set; + toggle: (key: string) => void; + onLink: (compRec: any, dirRec: any, d: any) => void; +}) { + const entNo = (company.enterprise_no || "").replace(/\s/g, ""); + const entName = company.enterprise_name || "—"; + const entStatus = company.enterprise_status || "—"; + const directors: any[] = company.directors || []; + const inDb = !!dbCompany; + const compSelectKey = `company:${company.enterprise_no}`; + + return ( +
+
+
+
+
+

{entName}

+ + {entStatus} + +
+

{entNo}

+
+
+ {inDb ? ( + + ✅ In DB + + ) : ( + + )} +
+
+
+ +
+

+ Director Details + + ({directors.length} director{directors.length === 1 ? "" : "s"}) + +

+ {directors.length === 0 ? ( +

No directors found in registry.

+ ) : ( +
+ + + + + + + + + + + + + + {directors.map((d, i) => { + const masked = maskId(d.id_number); + const dirRec = getDirectorRecord(d); + const inDirDb = !!dirRec; + const dirSelectKey = `director:${masked}`; + const linked = inDirDb && inDb && isLinked(dbCompany, dirRec); + + return ( + + + + + + + + + + ); + })} + +
ID NumberName(s)SurnameStatusDB StatusLink
+ {!inDirDb && ( + toggle(dirSelectKey)} + className="w-4 h-4" + /> + )} + {masked || "—"}{d.names || "—"}{d.surname || "—"} + + {d.status || "—"} + + + {inDirDb ? ( + + ✅ In DB + + ) : ( + select to add + )} + + {!inDb ? ( + add company first + ) : !inDirDb ? ( + add director first + ) : linked ? ( + + 🔗 Linked + + ) : ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/app/bizportal-2/page.tsx b/app/bizportal-2/page.tsx new file mode 100644 index 0000000..075c505 --- /dev/null +++ b/app/bizportal-2/page.tsx @@ -0,0 +1,33 @@ +// app/bizportal-2/page.tsx +// Server Component. Mirrors the existing /bizportal page but uses +// the Python Playwright service (scripts/bizportal_service.py) for +// synchronous search. The old queue-based flow is untouched. + +import { Suspense } from "react"; +import PageHeader from "@/components/PageHeader"; +import BizPortal2Explorer from "./BizPortal2Explorer"; + +export const dynamic = "force-dynamic"; + +export default async function BizPortal2Page() { + return ( + <> + + Direct Playwright service — no agent, no polling. + Calls the Python FastAPI sidecar on 127.0.0.1:7001. + Test version · old /bizportal page left untouched. + + } + /> +
+ Loading…}> + + +
+ + ); +} diff --git a/app/bizportal/page.tsx b/app/bizportal/page.tsx index c28e77e..51fa0a3 100644 --- a/app/bizportal/page.tsx +++ b/app/bizportal/page.tsx @@ -2,6 +2,7 @@ // Server Component. Renders the BizPortal search interface. import { Suspense } from "react"; +import Link from "next/link"; import PageHeader from "@/components/PageHeader"; import BizPortalExplorer from "./BizPortalExplorer"; @@ -14,6 +15,15 @@ export default async function BizPortalPage() { eyebrow="BizPortal" title="CIPC Entity Search" subtitle="Search entities on BizPortal by Director ID Number or Enterprise (CK) Number. Results are fetched live from the CIPC registry." + action={ + + Test + Try BizPortal 2.0 (Python sidecar) → + + } />
Loading…}> diff --git a/components/NavBar.tsx b/components/NavBar.tsx index 47e427b..b91de50 100644 --- a/components/NavBar.tsx +++ b/components/NavBar.tsx @@ -24,6 +24,7 @@ import type { ReactNode } from "react"; const LINKS = [ { href: "/", label: "Overview", icon: IconHome }, { href: "/bizportal", label: "BizPortal", icon: IconSearch }, + { href: "/bizportal-2", label: "BizPortal 2.0", icon: IconSearch, badge: "test" }, { href: "/directors", label: "Directors", icon: IconUsers }, { href: "/companies", label: "Companies", icon: IconBuilding }, { href: "/groups", label: "Groups", icon: IconTag }, @@ -66,15 +67,20 @@ export default function NavBar() { - {link.label} + {link.label} + {"badge" in link && link.badge ? ( + + {link.badge} + + ) : null} ); })} diff --git a/scripts/.env.example b/scripts/.env.example new file mode 100644 index 0000000..a736254 --- /dev/null +++ b/scripts/.env.example @@ -0,0 +1,15 @@ +# BizPortal gateway credentials. Fill in once, never commit. +BIZPORTAL_ID_NO=8709225206083 +BIZPORTAL_PASSWORD=Mull2ZerX1000 + +# Run the browser headless on the server. Set to false for debugging. +BIZPORTAL_HEADLESS=true + +# Minimum CIPC prepaid balance required to lodge a name reservation. +BIZPORTAL_BALANCE_MIN=50 + +# Navigation timeout (ms) for slow BizPortal pages. +BIZPORTAL_NAV_TIMEOUT_MS=30000 + +# Logging — DEBUG for selector-trails, INFO for normal use. +LOG_LEVEL=INFO diff --git a/scripts/.gitignore b/scripts/.gitignore new file mode 100644 index 0000000..faea9bf --- /dev/null +++ b/scripts/.gitignore @@ -0,0 +1,5 @@ +storage_state.json +.venv/ +__pycache__/ +*.pyc +.env diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..4f27089 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,162 @@ +# BizPortal 2.0 — Python Playwright service + +Sidecar that drives `https://www.bizportal.gov.za` via a real headless +Chromium. The Next.js side talks to it over HTTP — no LLM agent, no +file queue, no polling. Sits next to the existing `/bizportal` page +without touching it. + +## Why this exists + +The existing `/bizportal` page queues searches to `/tmp/bizportal_queue/` +and an LLM agent reads the page snapshot, decides what to click, and +writes the result back. Fast enough when it works, but the agent is +the brittle bit — it misreads the page, times out, or clicks the +wrong button. This service replaces that with a deterministic Python +script. + +## Architecture + +``` +Browser ─► /bizportal-2 ─► /api/bizportal-2/* ─► this service ─► BizPortal + │ + └─► PocketBase (via service) +``` + +The Python service holds a persistent `storage_state.json` so the +user is logged in once and every subsequent request reuses the +session. + +## Setup + +```bash +cd scripts +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +playwright install chromium +cp .env.example .env # fill in real credentials +``` + +## Run + +```bash +# Dev +uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 --reload + +# Production (Coolify / systemd) +uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 --workers 1 +``` + +Keep `--workers 1` — the script uses a single shared browser context +to preserve the logged-in session. + +## Endpoints + +### `GET /health` + +```json +{ + "ok": true, + "service": "bizportal-2", + "playwright_ready": true, + "logged_in_cached": true, + "headless": true +} +``` + +### `POST /search` + +```json +{ "type": "ck", "query": "2022/808158/08" } +``` + +```json +{ + "ok": true, + "query": "2022/808158/08", + "type": "ck", + "results": [ + { + "enterprise_no": "2022/808158/08", + "enterprise_name": "AMPLEFORTH TRADING (PTY) LTD", + "enterprise_type": "Private Company", + "enterprise_status": "Business is in Business", + "directors": [ + { + "id_number": "870922XXXX08X", + "names": "DAVID", + "surname": "ELS", + "status": "Active", + "type": "Director" + } + ] + } + ], + "log": ["Navigating to login", "..."] +} +``` + +### `POST /reserve` + +```json +{ "names": ["AMPLEFORTH PTY LTD", "AMPLEFORTH TRADING", "AMPLEFORTH SA", "AMPLEFORTH HOLDINGS"], "dry_run": false } +``` + +```json +{ + "ok": true, + "balance_before": 412.50, + "status": "bizportal_done", + "log": ["..."], + "message": "Submitted via Bizportal. The R50 fee will be debited later." +} +``` + +## Tunable selectors + +BizPortal uses ASP.NET WebForms (`cntMain_*` IDs). The known-stable +IDs for login and name reservation are baked in. The **search** field +IDs are not 100% verified — try a search after starting the service +once and tune the `DIRECTOR_SEARCH_FIELD_CANDIDATES` / +`ENTERPRISE_SEARCH_FIELD_CANDIDATES` lists at the top of +`bizportal_service.py` if you get a "Could not find …" error. + +The page URL is logged in the error so you can inspect the live page +in your browser and grab the right field ID. + +## Resetting the session + +```bash +rm scripts/storage_state.json +# Restart the service — it will log in once and re-cache the session. +``` + +## Running as a systemd service + +```ini +[Unit] +Description=BizPortal 2.0 Python service +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/var/www/workflow/scripts +EnvironmentFile=/var/www/workflow/scripts/.env +ExecStart=/var/www/workflow/scripts/.venv/bin/uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 --workers 1 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +## What this does NOT replace + +- The existing `/bizportal` page (kept untouched) +- The `/api/bizportal/search/*` queue routes (kept untouched) +- The `/api/register/[id]/apply-bizportal` route (kept untouched) +- The PocketBase collections and bulk-add / link logic + +If you decide to retire the old flow, see +`scripts/README.rollback.md` (TODO if you want one). diff --git a/scripts/bizportal_service.py b/scripts/bizportal_service.py new file mode 100644 index 0000000..f739bd4 --- /dev/null +++ b/scripts/bizportal_service.py @@ -0,0 +1,592 @@ +# ───────────────────────────────────────────────────────────────── +# BizPortal 2.0 — Python Playwright sidecar +# +# Tiny FastAPI service that drives https://www.bizportal.gov.za via +# a real headless Chromium. The Next.js side talks to this over HTTP +# (no LLM agent, no file queue, no polling). All UI selectors live +# here so the existing /bizportal page is untouched. +# +# Run: +# cd scripts +# python3 -m venv .venv && source .venv/bin/activate +# pip install -r requirements.txt +# playwright install chromium +# cp .env.example .env # fill in credentials +# uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 +# +# Smoke test: +# curl http://127.0.0.1:7001/health +# curl -X POST http://127.0.0.1:7001/search \ +# -H 'Content-Type: application/json' \ +# -d '{"type":"ck","query":"2022/808158/08"}' +# +# Endpoints: +# GET /health +# POST /search { type: "id"|"ck", query: str } +# POST /reserve { names: [str,str,str,str], dry_run: bool, balance_min: float=50 } +# ───────────────────────────────────────────────────────────────── + +import os +import re +import json +import asyncio +import logging +from pathlib import Path +from typing import List, Optional, Dict, Any + +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from playwright.async_api import async_playwright, Browser, BrowserContext, Page, TimeoutError as PWTimeout + +load_dotenv() + +# ───────────────────────────────────────────────────────────────── +# Config — credentials, endpoints, selectors. Tweak here, not in code. +# ───────────────────────────────────────────────────────────────── + +BIZPORTAL_BASE = "https://www.bizportal.gov.za" +LOGIN_URL = f"{BIZPORTAL_BASE}/login.aspx" +PROFILE_URL = f"{BIZPORTAL_BASE}/user_profile.aspx" +NAME_RESERVE_URL = f"{BIZPORTAL_BASE}/name_reserve.aspx" +BIZPROFILE_URL = f"{BIZPORTAL_BASE}/bizprofile.aspx" + +# Login fields — verified. +LOGIN_ID = "cntMain_txtIDNo" +LOGIN_PASSWORD = "cntMain_txtPassword" +LOGIN_BUTTON = "cntMain_btnLogin" + +# Name reservation fields — verified. +NAME_INPUTS = ["cntMain_txtPropName1", "cntMain_txtPropName2", "cntMain_txtPropName3", "cntMain_txtPropName4"] +NAME_CONTINUE_1 = "cntMain_btnContinuePropName" +NAME_CONTINUE_2 = "cntMain_btnContinueLodge" + +# BizProfile search — verified. ONE page: dropdown picks the search +# type, single input + button. Verified 2026-08-17 against the live +# site by probing with Playwright. +BIZPROFILE_DROPDOWN = "cntMain_drpSearchOptions" +BIZPROFILE_INPUT = "cntMain_txtSearchCIPC" +BIZPROFILE_BUTTON = "cntMain_btnSearch" +BIZPROFILE_DROPDOWN_OPTIONS = { + "id": "IDNo", # Director ID No. + "ck": "EntNo", # Enterprise No. + "name": "EntName", # Enterprise Name (unused for now) +} + +# Company details labels on bizprofile.aspx (CK search result page). +BIZPROFILE_LBL_ENT_NO = "cntMain_lblEntNo" +BIZPROFILE_LBL_ENT_NAME = "cntMain_lblEntName" +BIZPROFILE_LBL_ENT_TYPE = "cntMain_lblEntType" +BIZPROFILE_LBL_ENT_STATUS = "cntMain_lblEntStatus" + +# ID search returns a GridView with this id. +ID_SEARCH_GRID = "cntMain_gdvEnterprises" + +# Maximum number of companies to click-through for director lookup +# during an ID search. The existing UI can handle more, but each +# click is a postback round-trip — cap it to keep searches fast. +MAX_ID_SEARCH_DIRECTOR_FETCH = 10 + +# Session storage — single file, persistent across restarts. Logged-in +# cookies live here. Gitignored. +STORAGE_STATE_PATH = Path(__file__).parent / "storage_state.json" + +# Required CIPC prepaid balance for name reservation. +BALANCE_MIN = float(os.getenv("BIZPORTAL_BALANCE_MIN", "50")) + +# Browser behaviour. +HEADLESS = os.getenv("BIZPORTAL_HEADLESS", "true").lower() == "true" +NAV_TIMEOUT_MS = int(os.getenv("BIZPORTAL_NAV_TIMEOUT_MS", "30000")) + +# ───────────────────────────────────────────────────────────────── +# Logging +# ───────────────────────────────────────────────────────────────── + +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s [%(levelname)s] %(name)s — %(message)s", +) +log = logging.getLogger("bizportal-2") + +# ───────────────────────────────────────────────────────────────── +# Pydantic models +# ───────────────────────────────────────────────────────────────── + +class SearchRequest(BaseModel): + type: str = Field(pattern="^(id|ck)$") + query: str = Field(min_length=1, max_length=64) + +class ReserveRequest(BaseModel): + names: List[str] = Field(min_length=1, max_length=4) + dry_run: bool = False + balance_min: Optional[float] = None + +# ───────────────────────────────────────────────────────────────── +# App + browser lifecycle +# ───────────────────────────────────────────────────────────────── + +app = FastAPI(title="BizPortal 2.0", version="2.0.0") +_playwright = None +_browser: Optional[Browser] = None +_context: Optional[BrowserContext] = None +_browser_lock = asyncio.Lock() + +@app.on_event("startup") +async def on_startup(): + global _playwright, _browser, _context + _playwright = await async_playwright().start() + # Use the full Chromium (not the headless-shell variant) — the headless + # shell can hit EPERM on macOS when Playwright tries to clean up after + # the first request. The full Chromium is more stable across platforms. + launch_kwargs = { + "headless": HEADLESS, + "args": [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + ], + } + if HEADLESS: + # Force the full Chromium binary, not the headless-shell. + launch_kwargs["channel"] = "chromium" + _browser = await _playwright.chromium.launch(**launch_kwargs) + _context = await _open_context() + log.info("Service ready. headless=%s storage_state=%s", HEADLESS, STORAGE_STATE_PATH.exists()) + +@app.on_event("shutdown") +async def on_shutdown(): + if _context: + await _context.close() + if _browser: + await _browser.close() + if _playwright: + await _playwright.stop() + +async def _open_context() -> BrowserContext: + if STORAGE_STATE_PATH.exists(): + try: + ctx = await _browser.new_context(storage_state=str(STORAGE_STATE_PATH)) + log.info("Loaded storage state from %s", STORAGE_STATE_PATH) + return ctx + except Exception as e: + log.warning("Could not load storage state: %s — starting fresh", e) + return await _browser.new_context() + +def _current_context() -> BrowserContext: + if not _context: + raise RuntimeError("Browser context not initialised") + return _context + +# ───────────────────────────────────────────────────────────────── +# Health +# ───────────────────────────────────────────────────────────────── + +@app.get("/health") +async def health(): + return { + "ok": True, + "service": "bizportal-2", + "playwright_ready": _browser is not None and _context is not None, + "logged_in_cached": STORAGE_STATE_PATH.exists(), + "headless": HEADLESS, + } + +# ───────────────────────────────────────────────────────────────── +# Helpers — login, wait, parsing +# ───────────────────────────────────────────────────────────────── + +async def _save_storage_state() -> None: + state = await _context.storage_state() + STORAGE_STATE_PATH.write_text(json.dumps(state, indent=2)) + log.info("Saved storage state to %s", STORAGE_STATE_PATH) + +async def _is_logged_in(page: Page) -> bool: + try: + return await page.locator(LOGIN_ID).count() == 0 + except Exception: + return False + +async def _ensure_logged_in(page: Page, log_list: List[str]) -> None: + """Reuse stored session if valid; otherwise log in once.""" + if await _is_logged_in(page): + log_list.append("Already logged in (cached session)") + return + + log.info("Logging in…") + log_list.append("Navigating to login") + await page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) + + if await _is_logged_in(page): + log_list.append("Already logged in (after navigation)") + return + + id_no = os.getenv("BIZPORTAL_ID_NO") + password = os.getenv("BIZPORTAL_PASSWORD") + if not id_no or not password: + raise RuntimeError("BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD not set in environment") + + await page.locator(f"#{LOGIN_ID}").fill(id_no) + await page.locator(f"#{LOGIN_PASSWORD}").fill(password) + await page.locator(f"#{LOGIN_BUTTON}").click() + + # Wait for the login form to disappear. + try: + await page.locator(f"#{LOGIN_ID}").wait_for(state="detached", timeout=30000) + except PWTimeout: + raise RuntimeError("Login failed — login form still present after 30s") + + await _save_storage_state() + log_list.append("Logged in and cached session") + log.info("Logged in") + +async def _parse_balance(page: Page) -> Optional[float]: + await page.goto(PROFILE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) + await page.wait_for_timeout(1500) + text = await page.locator("body").inner_text() + matches = re.findall(r"CIPC Prepaid Account Balance[:\s]*R\s*([0-9][0-9.,]*)", text, re.IGNORECASE) + if not matches: + return None + raw = matches[0].replace(".", "").replace(",", ".") + try: + return float(raw) + except ValueError: + return None + +async def _select_first(page: Page, candidates: List[str], kind: str) -> str: + """Return the first selector that resolves. kind is for error msgs.""" + for sel in candidates: + try: + count = await page.locator(sel).count() + if count > 0: + return sel + except Exception: + continue + raise RuntimeError( + f"Could not find {kind} on {page.url}. " + f"Tried: {candidates}. " + f"Update the *_CANDIDATES constants in bizportal_service.py." + ) + + +async def _parse_results_table(page: Page) -> List[Dict[str, Any]]: + """Probe-based parse that knows the actual BizPortal layouts. + + Two distinct result shapes: + + CK search (dropdown=EntNo): ONE company shown on the page itself. + Labels: cntMain_lblEntNo/EntName/EntType/EntStatus hold the + company info. Directors live in a sub-table with columns + `ID Number | Name(s) | Surname | Type | Status`. + + ID search (dropdown=IDNo): a GridView of companies. + `cntMain_gdvEnterprises` has columns Enterprise Number, Name, + Status, Director Status, [hidden Status], View. Each row has + a View button that triggers a postback to load that company's + directors. We click through each one (up to a cap) and parse + the directors from the re-rendered page. + """ + # Detect which layout we're in. + has_company_labels = await page.locator(f"#{BIZPROFILE_LBL_ENT_NO}").count() > 0 + has_grid = await page.locator(f"#{ID_SEARCH_GRID}").count() > 0 + + if has_company_labels: + return [await _parse_company_layout(page)] + if has_grid: + return await _parse_grid_layout(page) + + log_list: List[str] = [] + raise RuntimeError( + "Unrecognised result layout. Neither company labels nor " + f"the GridView were found on {page.url}." + ) + + +async def _parse_company_layout(page: Page) -> Dict[str, Any]: + """CK search result — single company, directors in a sub-table.""" + async def lbl(suffix: str) -> str: + loc = page.locator(f"#cntMain_lbl{suffix}") + if await loc.count() == 0: + return "" + return (await loc.first.inner_text()).strip() + + company = { + "enterprise_no": await lbl("EntNo"), + "enterprise_name": await lbl("EntName"), + "enterprise_type": await lbl("EntType"), + "enterprise_status": await lbl("EntStatus"), + "directors": [], + } + + # Directors table — find the table whose header row contains + # "ID Number". Verified ordering: ID Number | Name(s) | Surname + # | Type | Status. + tables = await page.locator("table").all() + for t in tables: + try: + headers = await t.locator("th").all() + header_text = [(await h.inner_text()).strip() for h in headers] + except Exception: + continue + if not header_text: + continue + if any("ID Number" in h for h in header_text): + cols = {"id": None, "names": None, "surname": None, "type": None, "status": None} + for i, h in enumerate(header_text): + hl = h.lower() + if "id number" in hl: + cols["id"] = i + elif "name(s)" in hl or hl.startswith("name"): + cols["names"] = i + elif "surname" in hl: + cols["surname"] = i + elif "type" in hl: + cols["type"] = i + elif "status" in hl: + cols["status"] = i + for row in await t.locator("tbody tr").all(): + cells = await row.locator("td").all() + if not cells: + continue + cell_text = [] + for c in cells: + try: + cell_text.append((await c.inner_text()).strip()) + except Exception: + cell_text.append("") + if all(not x for x in cell_text): + continue + def at(k: str) -> str: + i = cols.get(k) + if i is None or i >= len(cell_text): + return "" + return cell_text[i] + company["directors"].append({ + "id_number": at("id"), + "names": at("names"), + "surname": at("surname"), + "status": at("status"), + "type": at("type") or "Director", + }) + break + + return company + + +async def _parse_grid_layout(page: Page) -> List[Dict[str, Any]]: + """ID search result — list of companies in a GridView. + + Returns the company list immediately. Directors are NOT fetched + here — the caller can request them per-company via the + /directors/ endpoint. This keeps the search + fast even when the ID has many director roles. + """ + companies: List[Dict[str, Any]] = [] + rows = await page.locator(f"#{ID_SEARCH_GRID} tbody tr").all() + for row in rows: + cells = await row.locator("td").all() + if len(cells) < 4: + continue + cell_text = [] + for c in cells: + try: + cell_text.append((await c.inner_text()).strip()) + except Exception: + cell_text.append("") + if not cell_text[0]: + continue + companies.append({ + "enterprise_no": cell_text[0], + "enterprise_name": cell_text[1] if len(cell_text) > 1 else "", + "enterprise_status": cell_text[2] if len(cell_text) > 2 else "", + "director_status": cell_text[3] if len(cell_text) > 3 else "", + "directors": [], + }) + return companies + + +async def _fetch_directors_for_company(page: Page, enterprise_no: str) -> List[Dict[str, Any]]: + """ID search follow-up — do a separate CK search for the given + enterprise number and return its directors. + + Clicking the View button on the GridView navigates to the + company details page (the GridView is gone afterwards), so we + can't click multiple in a row. Doing a fresh CK search is the + simplest reliable path. + """ + await page.goto(BIZPROFILE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) + await page.wait_for_timeout(1500) + await page.locator(f"#{BIZPROFILE_DROPDOWN}").select_option("EntNo") + try: + await page.wait_for_load_state("domcontentloaded", timeout=15000) + except Exception: + pass + await page.wait_for_timeout(1500) + await page.locator(f"#{BIZPROFILE_INPUT}").fill(enterprise_no) + await page.locator(f"#{BIZPROFILE_BUTTON}").click() + await page.wait_for_load_state("domcontentloaded", timeout=NAV_TIMEOUT_MS) + await page.wait_for_timeout(2500) + company = await _parse_company_layout(page) + return company["directors"] + +# ───────────────────────────────────────────────────────────────── +# POST /search +# ───────────────────────────────────────────────────────────────── + +@app.post("/search") +async def search(req: SearchRequest): + log_list: List[str] = [] + async with _browser_lock: + page = await _context.new_page() if _context else None + if not page: + raise HTTPException(503, "Browser not ready") + try: + await _ensure_logged_in(page, log_list) + + option = BIZPROFILE_DROPDOWN_OPTIONS.get(req.type) + if not option: + return JSONResponse( + {"ok": False, "error": f"Unknown search type: {req.type}", "log": log_list}, + status_code=400, + ) + + log_list.append(f"GET {BIZPROFILE_URL}") + await page.goto(BIZPROFILE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) + await page.wait_for_timeout(1500) + + log_list.append(f"Select dropdown: {option}") + await page.locator(f"#{BIZPROFILE_DROPDOWN}").select_option(option) + # Dropdown change triggers a postback — wait for the page to settle. + try: + await page.wait_for_load_state("domcontentloaded", timeout=15000) + except Exception: + pass + await page.wait_for_timeout(1500) + + log_list.append(f"Fill query: {req.query}") + await page.locator(f"#{BIZPROFILE_INPUT}").fill(req.query) + await page.locator(f"#{BIZPROFILE_BUTTON}").click() + await page.wait_for_load_state("domcontentloaded", timeout=NAV_TIMEOUT_MS) + await page.wait_for_timeout(3000) + + companies = await _parse_results_table(page) + log_list.append(f"Parsed {len(companies)} companies") + + # For ID search, optionally click-through to fetch directors. + if req.type == "id" and companies: + capped = companies[:MAX_ID_SEARCH_DIRECTOR_FETCH] + skipped = len(companies) - len(capped) + log_list.append(f"Fetching directors for {len(capped)} companies (cap={MAX_ID_SEARCH_DIRECTOR_FETCH})") + for c in capped: + directors = await _fetch_directors_for_company(page, c["enterprise_no"]) + c["directors"] = directors + log_list.append(f" {c['enterprise_no']}: {len(directors)} directors") + if skipped: + log_list.append(f"Skipped {skipped} additional companies to keep search fast") + + return {"ok": True, "query": req.query, "type": req.type, "results": companies, "log": log_list} + + except Exception as e: + log.exception("Search failed") + log_list.append(f"ERROR: {e}") + return JSONResponse({"ok": False, "error": str(e), "log": log_list}, status_code=500) + finally: + await page.close() + +# ───────────────────────────────────────────────────────────────── +# POST /reserve +# ───────────────────────────────────────────────────────────────── + +@app.post("/reserve") +async def reserve(req: ReserveRequest): + log_list: List[str] = [] + balance_before: Optional[float] = None + threshold = req.balance_min if req.balance_min is not None else BALANCE_MIN + balance_before = None + step = "init" + + async with _browser_lock: + page = await _context.new_page() if _context else None + if not page: + raise HTTPException(503, "Browser not ready") + try: + names = [n for n in (req.names or []) if n and n.strip()] + if not names: + raise RuntimeError("No proposed names to apply") + log_list.append(f"Names: {names}") + + step = "login" + await _ensure_logged_in(page, log_list) + + step = "balance_before" + balance_before = await _parse_balance(page) + log_list.append(f"Balance BEFORE: {balance_before}") + if balance_before is None: + raise RuntimeError("Could not read CIPC Prepaid Account Balance") + if balance_before <= threshold: + raise RuntimeError( + f"Insufficient balance: R {balance_before:.2f} is not > R {threshold:.2f}. " + f"Top up the CIPC Prepaid Account before submitting." + ) + + step = "open_name_reservation" + log_list.append(f"GET {NAME_RESERVE_URL}") + await page.goto(NAME_RESERVE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS) + await page.wait_for_timeout(1500) + + step = "fill_names" + for i, name in enumerate(names[:4]): + sel = f"#{NAME_INPUTS[i]}" + count = await page.locator(sel).count() + if count == 0: + raise RuntimeError(f"Name field {sel} not found on name_reserve page") + await page.locator(sel).fill(name) + log_list.append(f"Filled {len(names[:4])} name boxes") + + step = "continue_1" + await page.locator(f"#{NAME_CONTINUE_1}").wait_for(state="visible", timeout=10000) + await page.locator(f"#{NAME_CONTINUE_1}").click() + log_list.append("Clicked first Continue") + await page.wait_for_timeout(2500) + + if req.dry_run: + log_list.append("DRY RUN — stopping before second Continue") + return { + "ok": True, + "dry_run": True, + "balance_before": balance_before, + "status": "dry_run", + "log": log_list, + "message": "Dry run complete. Cost review screen reached. Re-run with dry_run=false to lodge.", + } + + step = "continue_2" + await page.locator(f"#{NAME_CONTINUE_2}").wait_for(state="visible", timeout=10000) + await page.locator(f"#{NAME_CONTINUE_2}").click() + log_list.append("Clicked second Continue") + await page.wait_for_timeout(4000) + + return { + "ok": True, + "balance_before": balance_before, + "status": "bizportal_done", + "log": log_list, + "message": "Submitted via Bizportal. The R50 fee will be debited from your CIPC Prepaid Account at a later stage. You will receive an SMS confirmation.", + } + + except Exception as e: + log_list.append(f"ERROR at step={step}: {e}") + return JSONResponse( + {"ok": False, "error": str(e), "step": step, "balance_before": balance_before, "log": log_list}, + status_code=500, + ) + finally: + await page.close() + +# ───────────────────────────────────────────────────────────────── +# Entrypoint +# ───────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + import uvicorn + uvicorn.run("bizportal_service:app", host="127.0.0.1", port=7001, log_level="info") diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..cc8b017 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.5 +uvicorn[standard]==0.32.1 +playwright==1.49.0 +pydantic==2.9.2 +python-dotenv==1.0.1