From 4849ac76349ea1212a0d5f4dbf7e4c64d8c17767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 19:39:42 +0200 Subject: [PATCH] feat(api): add POST /api/onboarding/step for wizard progression Persists per-step progress with strict order enforcement: submitted step must equal current+1, step 4 flips onboardingCompletedAt and clears the hl_onboarding proxy cookie. Re-reads the fresh User row to defend against tab-race conditions, audit-logs every transition, and caps writes at 30 per 10 min per user to defang stuck-retry loops. 409 on out-of-order, 409 on already-completed, 422 on payload validation failure, 429 on rate limit. --- src/app/api/onboarding/step/route.ts | 149 +++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/app/api/onboarding/step/route.ts diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts new file mode 100644 index 000000000..e8e44800e --- /dev/null +++ b/src/app/api/onboarding/step/route.ts @@ -0,0 +1,149 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { setOnboardingPendingCookie } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; + +/** + * v1.4.25 W14b — POST /api/onboarding/step. + * + * Persists the user's progress through the rebuilt + * `/onboarding/[step]` wizard. Body: `{ step: number }` where `step` + * is 1..4. The endpoint enforces a strict step-by-step contract: + * + * - Submitted step must equal `current + 1` (no skipping ahead). + * - Submitting step 4 marks completion — `onboardingCompletedAt` + * flips from null to NOW() in the same write and the + * `hl_onboarding` proxy cookie is cleared. + * - Already-completed users (`onboardingCompletedAt != null`) + * receive a 409. Replays must call this endpoint only while the + * wizard is in-progress. + * + * Companion to: + * - `GET ` not implemented (the User row is already loaded into + * every server-rendered step page). + * - `POST /api/onboarding/complete` remains the legacy v1.4.20 + * completion path used by the old single-file wizard; the new + * flow uses this endpoint with `step: 4` instead. + * + * Rate limit: 30 writes / 10 min / user — generous for a 4-step flow + * but tight enough to defang a stuck retry loop. + */ + +const stepBodySchema = z.object({ + step: z.number().int().min(1).max(4), +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "onboarding.step" } }); + + const rl = await checkRateLimit( + `onboarding-step:${user.id}`, + 30, + 10 * 60 * 1000, + ); + if (!rl.allowed) { + annotate({ + action: { name: "onboarding.step" }, + meta: { outcome: "rate_limited" }, + }); + return apiError("Too many onboarding writes, try again later", 429); + } + + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = stepBodySchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "onboarding.step" }, + meta: { outcome: "validation_failed" }, + }); + return apiError("Invalid step payload", 422); + } + const { step } = parsed.data; + + // Re-fetch the fresh User row so concurrent step submissions in + // separate tabs can't race past each other — the session.user + // snapshot might be a request-old. + const fresh = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + onboardingStep: true, + onboardingCompletedAt: true, + }, + }); + if (!fresh) { + return apiError("User not found", 404); + } + + if (fresh.onboardingCompletedAt) { + annotate({ + action: { name: "onboarding.step" }, + meta: { outcome: "already_completed" }, + }); + return apiError("Onboarding already completed", 409); + } + + const current = fresh.onboardingStep ?? 0; + if (step !== current + 1) { + annotate({ + action: { name: "onboarding.step" }, + meta: { outcome: "out_of_order", current, requested: step }, + }); + return apiError( + `Step out of order — current step is ${current}`, + 409, + ); + } + + const completing = step === 4; + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { + onboardingStep: step, + ...(completing ? { onboardingCompletedAt: new Date() } : {}), + }, + select: { + onboardingStep: true, + onboardingCompletedAt: true, + }, + }); + + if (completing) { + // Mirror /api/onboarding/complete — clear the proxy-readable + // pending cookie so the next navigation drops the /onboarding + // redirect immediately. + await setOnboardingPendingCookie(false); + } + + await auditLog("onboarding.step", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + step, + completed: completing, + }, + }); + + annotate({ + action: { name: "onboarding.step" }, + meta: { outcome: completing ? "completed" : "advanced", step }, + }); + + return apiSuccess({ + step: updated.onboardingStep, + onboardingCompletedAt: updated.onboardingCompletedAt, + }); +});