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.
156 lines
5.2 KiB
TypeScript
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 });
|
|
}
|