Workflow-App/Workflow/app/api/onboarding/submit/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

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 });
}