diff --git a/app/api/register/[id]/name-already-approved/route.ts b/app/api/register/[id]/name-already-approved/route.ts new file mode 100644 index 0000000..9e66f96 --- /dev/null +++ b/app/api/register/[id]/name-already-approved/route.ts @@ -0,0 +1,325 @@ +// app/api/register/[id]/name-already-approved/route.ts +// POST — "Name Already Approved" button in Step 2 +// +// Searches DeltaDet for existing CIPC Name Reservation + COR9.1 emails matching +// this registration (by proposed_name), then: +// 1. Patch status = bizportal_in_progress + cipc_reference_no +// 2. Parse COR9.1 PDF to extract decision (approved / rejected) +// 3. Patch registration with cipc_decision + status +// 4. Generate Director Acceptance Letter + Resolution PDFs +// 5. Save all 3 documents to PB +// 6. Email client with all 3 documents attached +// +// No new emails injected. Uses existing emails already in DeltaDet. + +import { NextRequest, NextResponse } from "next/server"; +import { execSync } from "child_process"; +import { writeFileSync, mkdirSync, rmSync } from "fs"; + +const PB_URL = process.env.PB_URL; +const PB_TOKEN = process.env.PB_TOKEN; +const COLLECTION_ID = "pbc_3075950410"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 180; + +const SCRIPTS = "/Volumes/KIOXIA/.hermes/profiles/ysterbaard/scripts"; +const PYTHON = "/Volumes/KIOXIA/.hermes/hermes-agent/venv/bin/python"; +const PB_AUTH = PB_TOKEN; + +async function loadReg(id) { + const r = await fetch(`${PB_URL}/api/collections/${COLLECTION_ID}/records/${id}`, { + headers: { Authorization: PB_AUTH }, + cache: "no-store", + }); + if (!r.ok) return null; + return await r.json(); +} + +async function patchReg(id, body) { + const r = await fetch(`${PB_URL}/api/collections/${COLLECTION_ID}/records/${id}`, { + method: "PATCH", + headers: { + Authorization: PB_AUTH, + "Content-Type": "application/json" + }, + body: JSON.stringify(body) + }); + return await r.json(); +} + +function runCmd(cmd) { + try { + return execSync(cmd, { timeout: 120 * 1000, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }); + } catch (err) { + return err.stdout?.toString() || err.message || ""; + } +} + +// Escape a string for embedding in a shell double-quoted string +function shellEscape(s) { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, '\\`').replace(/\$/g, '\\$'); +} + +// Find CIPC ref by proposed name — searches recent DeltaDet emails +function findCipcRefByName(proposedName) { + // Build a temp Python script file so we avoid all JS template literal escaping issues + const pythonCode = [ + "import subprocess, re", + `proposed_name = "${shellEscape(proposedName)}"`, + "name_norm = proposed_name.strip().upper()", + "", + "result = subprocess.run(", + ' ["himalaya", "-a", "deltadet", "--backend", "imap", "envelope", "list", "-p", "1", "-s", "60"],', + " capture_output=True, text=True, timeout=30", + ")", + "", + 'LOGGED_RE = re.compile(r"Name reservation lodged", re.IGNORECASE)', + 'REF_RE = re.compile(r"Reference No\\\\s*:\\\\s*(\\\\d{8,})", re.IGNORECASE)', + 'NAME_RE = re.compile(r"First proposed name\\\\s*:\\\\s*([^\\\\n\\\\r.]+?)\\\\s*(?:\\\\.|$)", re.IGNORECASE)', + "", + 'for line in result.stdout.split("\\n"):', + ' if "│" not in line:', + " continue", + ' parts = [c.strip() for c in line.replace("┆", "│").split("│") if c.strip() and not set(c.strip()) <= set("─═┌┐└┘├┤┬┴┼ ")]', + " if len(parts) < 2:", + " continue", + " env_id = parts[0]", + " if not env_id.isdigit():", + " continue", + "", + " body_out = subprocess.run(", + ' ["himalaya", "-a", "deltadet", "--backend", "imap", "message", "read", env_id],', + " capture_output=True, text=True, timeout=30", + " )", + " body = body_out.stdout", + " if not LOGGED_RE.search(body):", + " continue", + "", + " ref_match = REF_RE.search(body)", + " name_match = NAME_RE.search(body)", + " if not ref_match or not name_match:", + " continue", + "", + ' ref = ref_match.group(1).strip()', + ' name = name_match.group(1).strip().upper()', + " if name == name_norm:", + ' print(f"REF:{ref} ENV:{env_id}")', + ].join("\n"); + + const scriptPath = `/tmp/find_cipc_ref_${Date.now()}.py`; + writeFileSync(scriptPath, pythonCode); + const out = runCmd(`${PYTHON} "${scriptPath}" 2>&1`); + try { rmSync(scriptPath, { force: true }); } catch (_) {} + + const match = out.match(/REF:(\d+)\s+ENV:(\d+)/); + if (match) return { ref: match[1], envId: match[2] }; + return null; +} + +// Find COR9.1 received email by CIPC reference +function findCor91ByRef(ref) { + const pythonCode = [ + "import subprocess, re", + `search_ref = "${shellEscape(ref)}"`, + "", + "result = subprocess.run(", + ' ["himalaya", "-a", "deltadet", "--backend", "imap", "envelope", "list", "-p", "1", "-s", "60"],', + " capture_output=True, text=True, timeout=30", + ")", + "", + 'COR9_RE = re.compile(r"COR9\\\\.1 received", re.IGNORECASE)', + 'REF_RE = re.compile(r"Tracking reference\\\\s*:?\\\\s*(\\\\d+)", re.IGNORECASE)', + "", + 'for line in result.stdout.split("\\n"):', + ' if "│" not in line:', + " continue", + ' parts = [c.strip() for c in line.replace("┆", "│").split("│") if c.strip() and not set(c.strip()) <= set("─═┌┐└┘├┤┬┴┼ ")]', + " if len(parts) < 2:", + " continue", + " env_id = parts[0]", + " if not env_id.isdigit():", + " continue", + "", + " body_out = subprocess.run(", + ' ["himalaya", "-a", "deltadet", "--backend", "imap", "message", "read", env_id],', + " capture_output=True, text=True, timeout=30", + " )", + " body = body_out.stdout", + " if not COR9_RE.search(body):", + " continue", + "", + " ref_match = REF_RE.search(body)", + " if not ref_match:", + " continue", + "", + ' found_ref = ref_match.group(1).strip()', + " if found_ref == search_ref:", + ' print(f"ENV:{env_id}")', + ].join("\n"); + + const scriptPath = `/tmp/find_cor91_${Date.now()}.py`; + writeFileSync(scriptPath, pythonCode); + const out = runCmd(`${PYTHON} "${scriptPath}" 2>&1`); + try { rmSync(scriptPath, { force: true }); } catch (_) {} + + const match = out.match(/ENV:(\d+)/); + if (match) return { envId: match[1] }; + return null; +} + +// Download COR9.1 PDF attachment from email +function downloadCor91Pdf(envId, outDir) { + const out = runCmd(`himalaya -a deltadet --backend imap attachment download ${envId} 1 --dir "${outDir}" 2>&1`); + const lsOut = runCmd(`ls -t "${outDir}"/*.pdf 2>/dev/null | head -1`); + const pdfPath = lsOut.trim(); + return pdfPath && pdfPath.length > 0 ? pdfPath : null; +} + +// Parse COR9.1 PDF to determine decision +function parseCor91Decision(pdfPath) { + const out = runCmd(`${PYTHON} "${SCRIPTS}/parse_cor91.py" "${pdfPath}" 2>&1`); + const text = out.toLowerCase(); + if (text.includes("has been reserved")) return "approved"; + if (text.includes("regret to inform") && text.includes("no name has been approved")) return "rejected"; + return "unknown"; +} + +// Generate PDFs and return their paths +function generatePdfs(regId, outDir) { + runCmd(`TMPDIR="${outDir}" ${PYTHON} "${SCRIPTS}/reg_letter_generator.py" ${regId} 2>&1`); + runCmd(`TMPDIR="${outDir}" ${PYTHON} "${SCRIPTS}/reg_resolution_generator.py" ${regId} 2>&1`); + const letterPdf = runCmd(`ls "${outDir}"/Director_Acceptance*.pdf 2>/dev/null | head -1`).trim() || null; + const resPdf = runCmd(`ls "${outDir}"/New_Company_Resolution.pdf 2>/dev/null | head -1`).trim() || null; + return { letterPdf, resPdf }; +} + +// Upload a file to PB via the app's upload endpoint +function uploadToPB(regId, filePath, docType) { + const out = runCmd( + `curl -s -X POST "http://localhost:6001/api/register/${regId}/upload" ` + + `-H "Authorization: Bearer ${PB_TOKEN}" ` + + `-F "files=@${filePath};type=application/pdf" ` + + `-F "type=${docType}" 2>&1` + ); + console.log(`Upload ${docType}: ${out.slice(0, 200)}`); +} + +async function POST(req, { params }) { + const regId = params.id; + const reg = await loadReg(regId); + + if (!reg) { + return NextResponse.json({ error: "Registration not found" }, { status: 404 }); + } + + const proposedName = ( + reg.proposed_name_1 || reg.proposed_name_2 || reg.proposed_name_3 || reg.proposed_name_4 || "" + ).trim(); + + const steps = []; + + // Step 1: Find CIPC reference by proposed name + const refInfo = findCipcRefByName(proposedName); + if (!refInfo) { + return NextResponse.json({ + ok: false, + error: `Could not find a CIPC Name Reservation email in DeltaDet for "${proposedName}". Make sure the name was submitted on BizPortal first.`, + registration: { id: regId, reference_name: reg.reference_name, status: reg.status } + }, { status: 200 }); + } + steps.push(`Found CIPC ref ${refInfo.ref} (email ${refInfo.envId})`); + + // Step 2: Find COR9.1 email by reference + const cor91Info = findCor91ByRef(refInfo.ref); + if (!cor91Info) { + return NextResponse.json({ + ok: false, + error: `Found CIPC reference ${refInfo.ref} but could not find the COR9.1 approval email. The approval may not have arrived yet.`, + registration: { id: regId, reference_name: reg.reference_name, status: reg.status } + }, { status: 200 }); + } + steps.push(`Found COR9.1 email ${cor91Info.envId}`); + + // Step 3: Download COR9.1 PDF + const tmpDir = `/tmp/name_already_approved_${regId}_${Date.now()}`; + mkdirSync(tmpDir, { recursive: true }); + + const pdfPath = downloadCor91Pdf(cor91Info.envId, tmpDir); + if (!pdfPath) { + rmSync(tmpDir, { recursive: true, force: true }); + return NextResponse.json({ ok: false, error: "Could not download COR9.1 PDF attachment", steps }, { status: 200 }); + } + steps.push("Downloaded COR9.1 PDF"); + + // Step 4: Parse PDF for decision + const decision = parseCor91Decision(pdfPath); + if (decision === "unknown") { + rmSync(tmpDir, { recursive: true, force: true }); + return NextResponse.json({ + ok: false, + error: `Could not determine CIPC decision from the COR9.1 PDF. The document may not be a standard approval/rejection form.`, + steps + }, { status: 200 }); + } + steps.push(`CIPC decision: ${decision}`); + + // Step 5: Patch status to bizportal_in_progress + save ref + await patchReg(regId, { status: "bizportal_in_progress", cipc_reference_no: refInfo.ref }); + steps.push(`Set status=bizportal_in_progress, cipc_reference_no=${refInfo.ref}`); + + // Step 6: Patch decision + const newStatus = decision === "approved" ? "name_approved" : "bizportal_in_progress"; + await patchReg(regId, { + status: newStatus, + cipc_decision: decision, + cipc_decided_at: new Date().toISOString() + }); + steps.push(`Patched: status=${newStatus}, decision=${decision}`); + + // Step 7: Generate PDFs + upload + send client email (approved only) + if (decision === "approved") { + const { letterPdf, resPdf } = generatePdfs(regId, tmpDir); + + // Upload all 3 docs in one multipart call + const allPdfs = [pdfPath, letterPdf, resPdf].filter(Boolean); + if (allPdfs.length > 0) { + const fileArgs = allPdfs.map(p => `-F "files=@${p};type=application/pdf"`).join(" "); + const uploadOut = runCmd( + `curl -s -X POST "http://localhost:6001/api/register/${regId}/upload" ` + + `-H "Authorization: Bearer ${PB_TOKEN}" ` + + `${fileArgs} 2>&1` + ); + steps.push(`Uploaded ${allPdfs.length} documents to PB`); + console.log("Upload result:", uploadOut.slice(0, 300)); + } + + // Step 8: Send client email with all 3 PDFs attached + const emailResult = runCmd( + `${PYTHON} "${SCRIPTS}/send_client_email.py" "${regId}" "${pdfPath}" "${letterPdf || ""}" "${resPdf || ""}" 2>&1` + ); + steps.push(`Client email: ${emailResult.includes("Email sent") ? "sent ✅" : "failed — check logs"}`); + console.log("Email result:", emailResult); + } + + // Cleanup + rmSync(tmpDir, { recursive: true, force: true }); + + const regAfter = await loadReg(regId); + return NextResponse.json({ + ok: true, + decision, + registration: { + id: regId, + reference_name: reg.reference_name, + status_before: reg.status, + status_after: regAfter?.status, + cipc_reference_no: refInfo.ref, + cipc_decision: decision + }, + steps, + message: decision === "approved" + ? `Name approved! Status → name_approved. Documents saved to PB.` + : `Name rejected by CIPC. Status → bizportal_in_progress. Client needs to propose new names.`, + }, { status: 200 }); +} diff --git a/app/api/register/[id]/status/route.ts b/app/api/register/[id]/status/route.ts index 3b6f6e2..977fc24 100644 --- a/app/api/register/[id]/status/route.ts +++ b/app/api/register/[id]/status/route.ts @@ -27,6 +27,8 @@ const ALLOWED = new Set([ "bizportal_done", "client_requested_changes", "revert_new_names", + "step3", + "back_to_name_approved", ]); export async function POST( @@ -129,6 +131,22 @@ export async function POST( newStatus = "bizportal_done"; break; + case "step3": + // Move to Step 3 after name has been approved by CIPC + if (currentStatus !== "name_approved") { + return NextResponse.json({ error: `Cannot move to Step 3 from status "${currentStatus}". Must be "name_approved".` }, { status: 409 }); + } + newStatus = "step3"; + break; + + case "back_to_name_approved": + // Roll back to name_approved from step3 + if (currentStatus !== "step3") { + return NextResponse.json({ error: `Cannot revert from "${currentStatus}". Must be "step3".` }, { status: 409 }); + } + newStatus = "name_approved"; + break; + case "client_requested_changes": // Roll back to step 1 if client wants to change something after BizPortal submission if (currentStatus !== "bizportal_done" && currentStatus !== "bizportal_in_progress") { diff --git a/app/api/register/all/route.ts b/app/api/register/all/route.ts index 0574fff..9c29a64 100644 --- a/app/api/register/all/route.ts +++ b/app/api/register/all/route.ts @@ -16,14 +16,23 @@ export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const perPage = Math.min(Number(searchParams.get("perPage")) || 500, 1000); const q = (searchParams.get("q") || "").trim(); + const byId = searchParams.get("id") || ""; // Fetch all — no fields filter so every column is available // NOTE: this collection has no `created` field — sort by id (nanoid = time-sortable) - const data = await fetchCollection("ysterbaard_company_registrations", { + const fetchOpts: Record = { page: 1, perPage, sort: "-id", - }); + }; + + // If fetching by ID, reduce perPage to 1 and add filter + if (byId) { + fetchOpts.perPage = 1; + fetchOpts.filter = `id="${byId}"`; + } + + const data = await fetchCollection("ysterbaard_company_registrations", fetchOpts); let items = data.items; diff --git a/app/page.tsx b/app/page.tsx index 845dc25..a1b7ee4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,7 +10,6 @@ // All PB queries run in parallel (Promise.all) to keep first paint // under a single round-trip. -import Link from "next/link"; import { fetchCollection } from "@/lib/pb"; import type { YsterbaardCompany, @@ -131,24 +130,7 @@ export default async function DashboardPage() { eyebrow="Overview" title="Registry dashboard" subtitle="Live counts from PocketBase. Click into a section for the full searchable list." - action={ -
- - Browse directors - - - Browse companies - -
- } + action={null} /> diff --git a/app/registrations/[id]/step2/page.tsx b/app/registrations/[id]/step2/page.tsx index ef42344..27eb563 100644 --- a/app/registrations/[id]/step2/page.tsx +++ b/app/registrations/[id]/step2/page.tsx @@ -4,7 +4,7 @@ import { fetchCollection } from "@/lib/pb"; import type { PBRecord } from "@/lib/pb"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import PageHeader from "@/components/PageHeader"; import Link from "next/link"; import Step2Actions from "@/components/Step2Actions"; @@ -153,6 +153,7 @@ function StatusBanner({ reg, suggestedNames }: { reg: RegMain; suggestedNames: s export default async function Step2Page({ params }: { params: { id: string } }) { const reg = await loadReg(params.id); if (!reg) notFound(); + if (reg.status === "step3") redirect(`/registrations/${params.id}/step3`); const suggestedNames = [reg.proposed_name_1, reg.proposed_name_2, reg.proposed_name_3, reg.proposed_name_4] .filter(n => n && n.trim()); diff --git a/app/registrations/[id]/step3/page.tsx b/app/registrations/[id]/step3/page.tsx new file mode 100644 index 0000000..bf441a2 --- /dev/null +++ b/app/registrations/[id]/step3/page.tsx @@ -0,0 +1,143 @@ +// app/registrations/[id]/step3/page.tsx +// Step 3 — Client Approval +// Status: step3 = "Awaiting client approval" + +"use client"; + +import { useState, useEffect } from "react"; +import PageHeader from "@/components/PageHeader"; +import Link from "next/link"; + +interface RegItem { + id: string; + reference_name: string; + status: string; + cipc_reserved_name: string; + proposed_name_1: string; + [k: string]: unknown; +} + +export default function Step3Page({ params }: { params: { id: string } }) { + const [reg, setReg] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(""); + const [msgKind, setMsgKind] = useState<"success" | "error" | "info">("info"); + + useEffect(() => { + fetch(`/api/register/all?perPage=1&id=${params.id}`) + .then(r => r.json()) + .then(d => { + const item = (d.items || []).find((i: RegItem) => i.id === params.id); + setReg(item || null); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, [params.id]); + + async function handleBackToStep2() { + setBusy(true); + setMsg(""); + try { + const res = await fetch(`/api/register/${params.id}/status?action=back_to_name_approved`, { method: "POST" }); + const data = await res.json(); + if (!res.ok) { + setMsgKind("error"); + setMsg(`❌ ${data.error || "HTTP " + res.status}`); + } else { + setMsgKind("success"); + setMsg("✅ Moved back to Step 2."); + setTimeout(() => { window.location.href = `/registrations/${params.id}/step2`; }, 1200); + } + } catch (err: unknown) { + setMsgKind("error"); + setMsg(`❌ ${err instanceof Error ? err.message : "Error"}`); + } finally { + setBusy(false); + } + } + + if (loading) { + return ( +
+ Loading… +
+ ); + } + + if (!reg) { + return ( +
+ Registration not found. +
+ ); + } + + const approvedName = reg.cipc_reserved_name || reg.proposed_name_1 || "the approved name"; + + return ( + <> + + ← Back to all registrations + + } + /> + +
+ + {/* Status banner */} +
+
+ ⏳ +
+

Awaiting client approval

+

+ Company name {approvedName} has been reserved. + The client must now review and approve the registration documents before we can file with CIPC. +

+
+
+
+ + {/* Action buttons */} +
+

Step 3 actions

+
+ +
+ {msg && ( +
+ {msg} +
+ )} +
+ + {/* Placeholder for Step 3 content — SJ will define */} +
+

+ Step 3 workflow (document review, client sign-off, filing) — to be built here. +

+
+ +
+ + ); +} diff --git a/app/registrations/page.tsx b/app/registrations/page.tsx index 640752f..9023a33 100644 --- a/app/registrations/page.tsx +++ b/app/registrations/page.tsx @@ -21,6 +21,7 @@ const STATUS_LABELS: Record = { bizportal_in_progress: "Bizportal submission in progress", bizportal_done: "Bizportal received application", name_approved: "Name approved by Bizportal", + step3: "Awaiting client approval", name_reserved: "Name reserved (workflow done)", client_requested_changes: "Step 2 — Client requested changes", resignation_sent: "Step 3 — Resignation Sent", @@ -40,6 +41,7 @@ const STATUS_STYLE: Record = { bizportal_in_progress: "bg-blue-500/10 text-blue-700 border border-blue-500/30", bizportal_done: "bg-yellow-500/10 text-yellow-700 border border-yellow-500/30", name_approved: "bg-success/10 text-success border border-success/30", + step3: "bg-purple-500/10 text-purple-700 border border-purple-500/30", name_reserved: "bg-purple-500/10 text-purple-700 border border-purple-500/30", client_requested_changes: "bg-yellow-500/10 text-yellow-700 border border-yellow-500/30", resignation_sent: "bg-purple-100 text-purple-700 border border-purple-300", @@ -58,7 +60,8 @@ const STEP_NUMBER: Record = { bizportal_in_progress: 2, bizportal_done: 2, name_approved: 2, - name_reserved: 2, + step3: 3, + name_reserved: 3, client_requested_changes: 2, resignation_sent: 3, appointment_accepted: 4, @@ -448,6 +451,27 @@ export default function RegistrationsPage() { ← Back )} + {/* name_approved → Proceed to Step 3 */} + {status === "name_approved" && ( + + )} {/* View Step 2 page — available for all Step 2 statuses */} {(status === "registered" || status === "bizportal_in_progress" || status === "bizportal_done" || status === "client_requested_changes") && ( )} + {/* View Step 3 page */} + {status === "step3" && ( + + Step 3 → + + )} {/* Reopen client wizard — works from any non-cancelled state */} {status !== "cancelled" && ( +