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.
276 lines
9.9 KiB
TypeScript
276 lines
9.9 KiB
TypeScript
// app/api/onboarding/submit/route.ts
|
|
// POST /api/onboarding/submit
|
|
//
|
|
// Public endpoint — anyone with a valid `access_token` can submit once.
|
|
//
|
|
// Flow:
|
|
// 1. Parse + validate the multipart form (payload + documents).
|
|
// 2. Hash the token and check if a row with this hash already exists.
|
|
// If yes → 409 (link already used).
|
|
// 3. Build the full submission record (reference_no, hash, payload,
|
|
// uploads, status='submitted', submitted_at=now) and POST it to PB.
|
|
// PB's UNIQUE INDEX on access_token_hash is the source of truth —
|
|
// two concurrent submits race for the index, one wins, the other
|
|
// gets a 400 from PB which we surface as 409.
|
|
// 4. Return the reference_no so the wizard can show it to the client.
|
|
//
|
|
// PB rules are all-locked so the browser cannot write directly; this
|
|
// endpoint is the only path that creates a submission.
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import {
|
|
createRecordMultipart,
|
|
fetchCollection,
|
|
type PBRecord,
|
|
} from "@/lib/pb";
|
|
import type {
|
|
OnboardingSubmissionPayload,
|
|
OnboardingDirectorPayload,
|
|
OnboardingShareholderPayload,
|
|
OnboardingCompany,
|
|
} from "@/lib/types";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const COLLECTION = "ysterbaard_onboarding_submissions";
|
|
|
|
function mintReferenceNo(): string {
|
|
const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
|
const suffix = randomBytes(2).toString("hex").toUpperCase();
|
|
return `ONB-${today}-${suffix}`;
|
|
}
|
|
|
|
interface SubmissionLookup extends PBRecord {
|
|
reference_no: string;
|
|
status: string;
|
|
}
|
|
|
|
function hashToken(token: string): string {
|
|
return createHash("sha256").update(token + ":" + COLLECTION).digest("hex");
|
|
}
|
|
|
|
async function findExistingByToken(token: string): Promise<SubmissionLookup | null> {
|
|
const hash = hashToken(token);
|
|
try {
|
|
const result = await fetchCollection<SubmissionLookup>(COLLECTION, {
|
|
filter: `access_token_hash = "${hash}"`,
|
|
perPage: 1,
|
|
fields: "id,reference_no,status",
|
|
});
|
|
return result.items[0] ?? null;
|
|
} catch {
|
|
// If the lookup itself fails, fall through — the create step below
|
|
// will still fail with the unique-index constraint if there's a row.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parsePayload(raw: string): OnboardingSubmissionPayload | null {
|
|
try {
|
|
const parsed = JSON.parse(raw) as OnboardingSubmissionPayload;
|
|
if (!parsed.client_name?.trim() || !parsed.client_email?.trim()) {
|
|
return null;
|
|
}
|
|
if (!parsed.company || !Array.isArray(parsed.directors)) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function validateCompany(c: OnboardingCompany): string | null {
|
|
if (!c || typeof c !== "object") return "Invalid company payload";
|
|
if (!Array.isArray(c.proposed_names) || c.proposed_names.length !== 3) {
|
|
return "Company must have a 3-slot proposed_names array";
|
|
}
|
|
if (!c.proposed_names[0].trim()) return "At least one proposed name required";
|
|
if (!c.physical_address_line_1?.trim()) return "Physical address line 1 required";
|
|
if (!c.physical_town_city?.trim()) return "Physical town/city required";
|
|
if (!c.physical_province?.trim()) return "Physical province required";
|
|
if (!c.physical_postal_code?.trim()) return "Physical postal code required";
|
|
if (!c.postal_address_line_1?.trim()) return "Postal address line 1 required";
|
|
if (!c.postal_town_city?.trim()) return "Postal town/city required";
|
|
if (!c.postal_province?.trim()) return "Postal province required";
|
|
if (!c.postal_postal_code?.trim()) return "Postal postal code required";
|
|
if (!c.company_email?.trim()) return "Company email required";
|
|
return null;
|
|
}
|
|
|
|
function validateDirector(d: OnboardingDirectorPayload): string | null {
|
|
if (!d.first_names?.trim()) return "Director first names required";
|
|
if (!d.surname?.trim()) return "Director surname required";
|
|
if (!/^\d{13}$/.test(d.id_no ?? "")) return "Director ID must be 13 digits";
|
|
if (!d.date_of_birth) return "Director date of birth required";
|
|
if (!d.residential_address_line_1?.trim()) return "Director residential address line 1 required";
|
|
if (!d.residential_town_city?.trim()) return "Director residential town/city required";
|
|
if (!d.residential_province?.trim()) return "Director residential province required";
|
|
if (!d.residential_postal_code?.trim()) return "Director residential postal code required";
|
|
if (!d.postal_address_line_1?.trim()) return "Director postal address line 1 required";
|
|
if (!d.postal_town_city?.trim()) return "Director postal town/city required";
|
|
if (!d.postal_province?.trim()) return "Director postal province required";
|
|
if (!d.postal_postal_code?.trim()) return "Director postal postal code required";
|
|
if (!d.email?.trim()) return "Director email required";
|
|
return null;
|
|
}
|
|
|
|
function validateShareholder(s: OnboardingShareholderPayload): string | null {
|
|
if (!s.name?.trim()) return "Shareholder name required";
|
|
if (!s.id_or_company_no?.trim()) return "Shareholder ID / reg no. required";
|
|
if (typeof s.share_percent !== "number" || s.share_percent <= 0 || s.share_percent > 100) {
|
|
return "Shareholder share % out of range";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let form: FormData;
|
|
try {
|
|
form = await req.formData();
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: "Could not parse multipart form body." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const accessToken = form.get("access_token");
|
|
if (typeof accessToken !== "string" || accessToken.length < 16) {
|
|
return NextResponse.json(
|
|
{ error: "Missing or invalid access_token." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const payloadRaw = form.get("payload");
|
|
if (typeof payloadRaw !== "string") {
|
|
return NextResponse.json(
|
|
{ error: "Missing payload." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
const payload = parsePayload(payloadRaw);
|
|
if (!payload) {
|
|
return NextResponse.json(
|
|
{ error: "Malformed payload." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Server-side validation (defence-in-depth — wizard also validates).
|
|
const companyErr = validateCompany(payload.company);
|
|
if (companyErr) {
|
|
return NextResponse.json({ error: companyErr }, { status: 400 });
|
|
}
|
|
if (!payload.directors.length) {
|
|
return NextResponse.json({ error: "At least one director required" }, { status: 400 });
|
|
}
|
|
for (const [idx, d] of payload.directors.entries()) {
|
|
const err = validateDirector(d);
|
|
if (err) {
|
|
return NextResponse.json({ error: `Director ${idx + 1}: ${err}` }, { status: 400 });
|
|
}
|
|
}
|
|
if (payload.shareholders.length) {
|
|
const total = payload.shareholders.reduce((s, sh) => s + sh.share_percent, 0);
|
|
if (Math.abs(total - 100) > 0.01) {
|
|
return NextResponse.json(
|
|
{ error: `Shareholder total must equal 100% (got ${total.toFixed(2)}%).` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
for (const [idx, s] of payload.shareholders.entries()) {
|
|
const err = validateShareholder(s);
|
|
if (err) {
|
|
return NextResponse.json(
|
|
{ error: `Shareholder ${idx + 1}: ${err}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look up by token hash — if a row already exists, this link was used.
|
|
const existing = await findExistingByToken(accessToken);
|
|
if (existing) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `This link has already been submitted (reference ${existing.reference_no}).`,
|
|
reference_no: existing.reference_no,
|
|
},
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
// Collect uploaded files.
|
|
const files = form.getAll("documents").filter((v): v is File => v instanceof File);
|
|
if (files.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: "At least one document (ID or proof of residence) is required." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Enforce per-file size limit (10 MB) — server-side guard so the PB
|
|
// upload can't be a denial-of-service vector.
|
|
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
for (const f of files) {
|
|
if (f.size > MAX_FILE_BYTES) {
|
|
return NextResponse.json(
|
|
{ error: `${f.name} exceeds 10 MB limit.` },
|
|
{ status: 413 },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Build the full record. PB will reject on the UNIQUE INDEX if a
|
|
// concurrent request got in first — we surface that as 409.
|
|
const referenceNo = mintReferenceNo();
|
|
const hash = hashToken(accessToken);
|
|
const clientNotes = (payload.client_notes ?? "").trim();
|
|
const fields = {
|
|
reference_no: referenceNo,
|
|
access_token_hash: hash,
|
|
client_name: payload.client_name.trim(),
|
|
client_email: payload.client_email.trim(),
|
|
client_telephone: payload.client_telephone?.trim() ?? "",
|
|
company_data: JSON.stringify(payload.company),
|
|
directors_data: JSON.stringify(payload.directors),
|
|
shareholders_data: JSON.stringify(payload.shareholders),
|
|
submitted_at: new Date().toISOString(),
|
|
status: "submitted",
|
|
// Client-supplied notes (may have been captured via the voice widget).
|
|
// Stored under internal_notes so operators see it in PB admin alongside
|
|
// other operator-side annotations. A dedicated client_notes PB column
|
|
// can be added later via a superuser schema migration.
|
|
...(clientNotes ? { internal_notes: clientNotes } : {}),
|
|
};
|
|
|
|
const fileAttachments = files.map((f) => ({
|
|
field: "documents",
|
|
file: f,
|
|
}));
|
|
|
|
try {
|
|
await createRecordMultipart(COLLECTION, fields, fileAttachments);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : "Unknown PB error";
|
|
console.error("[onboarding submit] createRecord failed:", message);
|
|
// PB returns 400 on UNIQUE INDEX violation — treat as 409 (already used).
|
|
const isDuplicate = /unique|duplicate|already/i.test(message);
|
|
if (isDuplicate) {
|
|
return NextResponse.json(
|
|
{ error: "This link has already been submitted." },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
return NextResponse.json(
|
|
{ error: `Submission storage failed: ${message}` },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
|
|
return NextResponse.json({ reference_no: referenceNo });
|
|
}
|