Workflow-App/Workflow/app/api/onboarding/[id]/process/route.ts
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

156 lines
5.2 KiB
TypeScript

// app/api/onboarding/[id]/process/route.ts
// POST /api/onboarding/:id/process
//
// Server-side: reads the submission, validates directors, creates one
// `ysterbaard_directors` record per director (skipping any whose
// id_no already exists), then flips the submission to status=processed
// and stamps processed_at + processed_director_ids.
import { NextRequest, NextResponse } from "next/server";
import { fetchRecord, fetchCollection, createRecord, updateRecord } from "@/lib/pb";
import type {
OnboardingSubmission,
OnboardingDirector,
} from "@/lib/types";
export const dynamic = "force-dynamic";
const SUBMISSIONS = "ysterbaard_onboarding_submissions";
const DIRECTORS = "ysterbaard_directors";
interface DirectorRecord {
[key: string]: unknown;
id: string;
id_no: string;
collectionId: string;
collectionName: string;
}
function safeArray<T>(value: unknown): T[] {
if (Array.isArray(value)) return value as T[];
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? (parsed as T[]) : [];
} catch {
return [];
}
}
return [];
}
export async function POST(
_req: NextRequest,
{ params }: { params: { id: string } },
) {
let submission: OnboardingSubmission;
try {
submission = await fetchRecord<OnboardingSubmission>(SUBMISSIONS, params.id);
} catch {
return NextResponse.json({ error: "Submission not found" }, { status: 404 });
}
if (submission.status === "processed") {
return NextResponse.json(
{ error: "Submission is already processed." },
{ status: 409 },
);
}
if (submission.status !== "submitted" && submission.status !== "draft") {
return NextResponse.json(
{ error: `Cannot process a ${submission.status} submission.` },
{ status: 409 },
);
}
const directors = safeArray<OnboardingDirector>(submission.directors_data);
if (directors.length === 0) {
return NextResponse.json(
{ error: "Submission has no directors." },
{ status: 400 },
);
}
// Flip status to processing so concurrent clicks don't double-process.
try {
await updateRecord(SUBMISSIONS, params.id, { status: "processing" });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown PB error";
return NextResponse.json({ error: `Status flip failed: ${message}` }, { status: 502 });
}
// Create director records. Skip ones that already exist by id_no.
const created: string[] = [];
const skipped: Array<{ id_no: string; reason: string }> = [];
for (const d of directors) {
if (!/^\d{13}$/.test(d.id_no ?? "")) {
skipped.push({ id_no: d.id_no, reason: "Invalid ID" });
continue;
}
// Look up by id_no first.
let existing: DirectorRecord | null = null;
try {
const list = await fetchCollection<DirectorRecord>(DIRECTORS, {
perPage: 1,
filter: `id_no = "${d.id_no}"`,
fields: "id,id_no",
});
existing = list.items[0] ?? null;
} catch {
// best-effort dedupe — fall through to create
}
if (existing) {
skipped.push({ id_no: d.id_no, reason: "Already exists" });
continue;
}
try {
const created_rec = await createRecord<{ id: string; collectionId: string; collectionName: string; [k: string]: unknown }>(DIRECTORS, {
id_no: d.id_no,
surname: d.surname.trim(),
first_names: d.first_names.trim(),
disqualification_status: null,
telephone: d.cell_phone?.trim() ?? "",
total_companies: 0,
active_companies: 0,
inactive_companies: 0,
first_seen: new Date().toISOString().slice(0, 10),
last_checked: new Date().toISOString().slice(0, 10),
residential_address_line_1: d.residential_address_line_1?.trim() ?? "",
residential_address_line_2: d.residential_address_line_2?.trim() ?? "",
residential_town_city: d.residential_town_city?.trim() ?? "",
residential_province: d.residential_province?.trim() ?? "",
residential_postal_code: d.residential_postal_code?.trim() ?? "",
postal_address_line_1: d.postal_address_line_1?.trim() ?? "",
postal_address_line_2: d.postal_address_line_2?.trim() ?? "",
postal_town_city: d.postal_town_city?.trim() ?? "",
postal_province: d.postal_province?.trim() ?? "",
postal_postal_code: d.postal_postal_code?.trim() ?? "",
email: d.email?.trim() ?? "",
date_of_birth: d.date_of_birth ?? "",
notes: `Created from onboarding submission ${submission.reference_no}. Email: ${d.email}; DOB: ${d.date_of_birth}.`,
});
created.push(created_rec.id);
} catch (err) {
skipped.push({
id_no: d.id_no,
reason: err instanceof Error ? err.message : "PB create failed",
});
}
}
try {
await updateRecord(SUBMISSIONS, params.id, {
status: "processed",
processed_at: new Date().toISOString(),
processed_director_ids: created,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown PB error";
return NextResponse.json(
{ error: `Finalize failed: ${message}`, created, skipped },
{ status: 502 },
);
}
return NextResponse.json({ created, skipped });
}