// 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 { const hash = hashToken(token); try { const result = await fetchCollection(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 }); }