From 021ea8f1ff6935fb093577495056d50e10c7637f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 11:03:36 +0200 Subject: [PATCH] feat(api): let a record answer the setup questions, and act on them Three writes, all on the caller's own record. `PATCH /api/onboarding/answers` saves one step the moment it is answered, so leaving the flow and coming back resumes where it stopped. It is idempotent because the whole computation is a pure function of the row and the body, including the first-result instant, which is kept rather than re-taken. There is no ordering contract at all: a back button has to work. `POST /api/onboarding/complete` keeps everything it did and gains the confirm screen's job. For a record that answered the questions it derives the module map once and applies it, and the once-only latch is the point: a second confirm would re-apply a questionnaire over decisions taken in Settings since. Cycle is switched on through its own column and never off, because the gate already derives an unanswered cycle from recorded sex and an untick is not a request to retract that. `POST /api/onboarding/restart` puts the steps back and clears the latch. It writes no module state whatever: a re-run never turns off something switched on by hand, and the safest way to hold that is for this route to have no opinion about modules at all. It also leaves the first-run redirect's own stamp alone, so asking the questions again does not push anybody back through the old wizard. All three refuse outright while the session is acting on somebody else's record, at every grant level, so a delegate can never write another record's setup. The route into a managed profile's flow belongs with the rest of that record's configuration and is not smuggled in here. The demo allowlist gains the three: the flow has to be completable there with preset answers, and none of them creates health data. --- src/app/api/onboarding/answers/route.ts | 142 ++++++++++++++++ src/app/api/onboarding/complete/route.ts | 127 +++++++++++++- src/app/api/onboarding/restart/route.ts | 109 ++++++++++++ src/lib/openapi/routes/onboarding.ts | 207 ++++++++++++++++++++++- src/proxy.ts | 8 + 5 files changed, 587 insertions(+), 6 deletions(-) create mode 100644 src/app/api/onboarding/answers/route.ts create mode 100644 src/app/api/onboarding/restart/route.ts diff --git a/src/app/api/onboarding/answers/route.ts b/src/app/api/onboarding/answers/route.ts new file mode 100644 index 000000000..25048eea7 --- /dev/null +++ b/src/app/api/onboarding/answers/route.ts @@ -0,0 +1,142 @@ +/** + * v1.39 (C1) — `PATCH /api/onboarding/answers`. + * + * One step at a time, saved the moment it is answered, so leaving the flow and + * coming back resumes where it stopped (design spec §Principles 4). The body + * names the step and either its answer or a deliberate skip; the union in + * `src/lib/validations/onboarding-needs.ts` is what makes "skipped, and here is + * the answer anyway" unrepresentable. + * + * Idempotent: the state is computed by a pure function from the stored row and + * the body, so sending the same step twice writes the same value twice and the + * second write changes nothing a reader can see. The one instant that could + * drift — the first-result completion stamp — is kept from the existing row + * rather than re-taken. + * + * `requireAuth()` rather than `requireRecordAuth`: this route writes the + * CALLER's own record and refuses outright while the session is acting on + * somebody else's (403 `sharing.not_permitted`), at every grant level. The + * storage is keyed by record so a guardian running a managed profile's setup + * has a row to write, but the route into it belongs with the rest of the + * managed-record configuration (#939 / C2), not here — a delegable declaration + * is a reviewed entry in the sharing surface guard, not a default. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { applyOnboardingAnswer } from "@/lib/onboarding/needs-apply"; +import { + ONBOARDING_RECORD_SELECT, + readOnboardingRecordState, + toOnboardingStateDto, +} from "@/lib/onboarding/needs-store"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { onboardingAnswerSchema } from "@/lib/validations/onboarding-needs"; + +export const dynamic = "force-dynamic"; + +const RATE_LIMIT = 120; +const RATE_WINDOW_MS = 10 * 60 * 1000; + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "onboarding.answer.save" } }); + + const rl = await checkRateLimit( + `onboarding-answers:${user.id}`, + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) { + annotate({ + action: { name: "onboarding.answer.save" }, + meta: { outcome: "rate_limited" }, + }); + return apiError("Too many onboarding writes, try again later", 429, { + errorCode: "onboarding.answers.rateLimited", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = onboardingAnswerSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "onboarding.answer.save" }, + meta: { outcome: "validation_failed" }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "onboarding.answers.invalid", + }); + } + const answer = parsed.data; + + const existing = await prisma.onboardingRecord.findUnique({ + where: { userId: user.id }, + select: ONBOARDING_RECORD_SELECT, + }); + const next = applyOnboardingAnswer( + readOnboardingRecordState(existing), + answer, + new Date(), + ); + + // The unit answers are the one step whose consequence lives outside this + // table: `User.glucoseUnit` and `User.unitPreference` are what every display + // surface already reads, so the flow writes them rather than inventing a + // second source of truth. What is kept beside them in `needs.units` is the + // answer as given, so a restart can show it back without re-deriving it from + // a column Settings may have changed since. + if (answer.step === "units" && answer.status !== "skipped") { + const data: { glucoseUnit?: string; unitPreference?: string } = {}; + if (answer.units.glucoseUnit) data.glucoseUnit = answer.units.glucoseUnit; + if (answer.units.unitPreference) { + data.unitPreference = answer.units.unitPreference; + } + if (Object.keys(data).length > 0) { + await prisma.user.update({ where: { id: user.id }, data }); + } + } + + const written = await prisma.onboardingRecord.upsert({ + where: { userId: user.id }, + // Field-by-field, never a spread of the parsed body: the three columns are + // the pure function's output, and nothing the client sent reaches Prisma + // without passing through it. + create: { + userId: user.id, + needsJson: toJson(next.needs), + stepsJson: toJson(next.steps), + firstResultJson: next.firstResult ? toJson(next.firstResult) : undefined, + }, + update: { + needsJson: toJson(next.needs), + stepsJson: toJson(next.steps), + ...(next.firstResult + ? { firstResultJson: toJson(next.firstResult) } + : {}), + }, + select: ONBOARDING_RECORD_SELECT, + }); + + annotate({ + action: { name: "onboarding.answer.save" }, + meta: { + step: answer.step, + status: answer.status === "skipped" ? "skipped" : "done", + }, + }); + + return apiSuccess({ onboarding: toOnboardingStateDto(written) }); +}); diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 4bfe35382..2246885f4 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -1,8 +1,20 @@ -import { prisma } from "@/lib/db"; +import { prisma, toJson } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; +import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; +import { normalisePrefs } from "@/lib/modules/gate"; +import { + deriveOnboardingModuleDefaults, + mergeDerivedModulePreferences, +} from "@/lib/modules/registry"; +import { + ONBOARDING_RECORD_SELECT, + readOnboardingRecordState, + toOnboardingStateDto, +} from "@/lib/onboarding/needs-store"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; @@ -10,6 +22,22 @@ import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; /** * Complete the onboarding flow. Saves optional profile data and marks * onboarding as completed. + * + * v1.39 (C1) — this is also the confirm screen's endpoint for the needs-based + * flow, and the two live side by side rather than one replacing the other. + * The legacy half is unchanged: the optional profile fields, the completion + * stamp on `User.onboardingCompletedAt`, and the cleared pending cookie. The + * needs half runs only for a record that actually answered the questions + * (`needs.recordTarget` is set) and is what turns the answers into a module + * map — ONCE, guarded by `OnboardingRecord.modulesDerivedAt`, because a + * re-derivation would re-apply the questionnaire over decisions taken in + * Settings since. `POST /api/onboarding/restart` clears that marker when the + * person asks for the questions again. + * + * The goal slugs of today (`src/lib/onboarding/goals.ts`, the four-step wizard + * at `POST /api/onboarding/step`) are untouched and stay live until C2 + * replaces that surface. They decide TILE ORDER; the answers below decide + * WHICH MODULES ARE ON. Neither reads the other. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -59,5 +87,100 @@ export const POST = apiHandler(async (request: NextRequest) => { // next navigation drops the /onboarding redirect immediately. await setOnboardingPendingCookie(false); - return apiSuccess({ completed: true }); + const onboarding = await completeNeedsFlow(user.id); + + return apiSuccess({ completed: true, ...(onboarding ? { onboarding } : {}) }); }); + +/** + * The needs half: derive the module map once, mark the confirm step, stamp the + * flow's own completion. + * + * Returns the published state, or `null` for a caller that never entered the + * needs flow — a legacy wizard completion then answers exactly the body it + * always did. + */ +async function completeNeedsFlow(userId: string) { + const record = await prisma.onboardingRecord.findUnique({ + where: { userId }, + select: ONBOARDING_RECORD_SELECT, + }); + if (!record) return null; + + const state = readOnboardingRecordState(record); + if (state.needs.recordTarget === null) { + // The row exists but Q1 was never answered, so there is nothing to derive + // from. Stamping a completion here would claim a setup that did not happen. + annotate({ + action: { name: "onboarding.needs.complete" }, + meta: { outcome: "no_answers" }, + }); + return toOnboardingStateDto(record); + } + + let derived = false; + if (record.modulesDerivedAt === null) { + const defaults = deriveOnboardingModuleDefaults({ + recordTarget: state.needs.recordTarget, + areas: state.needs.areas, + // An unanswered (skipped) question is the conservative answer: no + // medication surface, no doctor report. Both stay one click away in + // Settings, which is the whole ordering-versus-removal decision. + medication: state.needs.medication ?? "no", + visit: state.needs.visit ?? "no", + }); + + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { modulePreferencesJson: true }, + }); + const merged = mergeDerivedModulePreferences( + normalisePrefs(row?.modulePreferencesJson), + defaults.preferences, + ); + await prisma.user.update({ + where: { id: userId }, + data: { modulePreferencesJson: merged }, + }); + + // `cycle` delegates to `CycleProfile.cycleTrackingEnabled`, so the module + // blob never owns it. Only ever switched ON here: the gate derives an + // unanswered cycle from recorded sex, and an untick is not a request to + // retract that. + if (defaults.cycleTracking) { + await getOrCreateCycleProfile(userId); + await prisma.cycleProfile.update({ + where: { userId }, + data: { cycleTrackingEnabled: true }, + }); + } + + // A module toggle adds or removes a Health Score pillar, so the cached + // composite was computed from a composition that no longer holds. + invalidateUserHealthScore(userId); + derived = true; + } + + const now = new Date(); + const steps = state.steps.map((step) => + step.id === "confirm" && step.status === "pending" + ? { ...step, status: "done" as const } + : step, + ); + const written = await prisma.onboardingRecord.update({ + where: { userId }, + data: { + stepsJson: toJson(steps), + completedAt: now, + ...(derived ? { modulesDerivedAt: now } : {}), + }, + select: ONBOARDING_RECORD_SELECT, + }); + + annotate({ + action: { name: "onboarding.needs.complete" }, + meta: { outcome: derived ? "derived" : "already_derived" }, + }); + + return toOnboardingStateDto(written); +} diff --git a/src/app/api/onboarding/restart/route.ts b/src/app/api/onboarding/restart/route.ts new file mode 100644 index 000000000..0990e898b --- /dev/null +++ b/src/app/api/onboarding/restart/route.ts @@ -0,0 +1,109 @@ +/** + * v1.39 (C1) — `POST /api/onboarding/restart`. + * + * "Set up again", from Settings. It puts the nine steps back to `pending`, + * clears the flow's completion stamp and clears the derivation marker so the + * next confirm may derive a module map again. + * + * What it deliberately does NOT do: + * + * - touch a single module. The design spec's ordering-versus-removal + * decision says a restart never turns off a module the person turned on by + * hand, and the safest way to hold that is for this route to write no + * module state at all. The protection against the SECOND confirm is + * elsewhere, in `mergeDerivedModulePreferences`. + * - clear the answers. They come back as the prefill for the re-run, which + * is what "never re-ask a value the account holds" means here. + * - clear `User.onboardingCompletedAt`. That column gates the first-run + * redirect in `src/proxy.ts`; clearing it would drop the person back into + * the wizard of today rather than into the questions they asked for. + * - forget a first result that really happened. The task produced a reading, + * a medication or a connection; a re-run of the questions does not unmake + * it. + * + * `requireAuth()` for the reason the answers route gives: the caller's own + * record, refused outright under an acting-account switch. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma, toJson } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { defaultOnboardingSteps } from "@/lib/onboarding/needs"; +import { + ONBOARDING_RECORD_SELECT, + toOnboardingStateDto, +} from "@/lib/onboarding/needs-store"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { onboardingRestartSchema } from "@/lib/validations/onboarding-needs"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "onboarding.needs.restart" } }); + + const rl = await checkRateLimit( + `onboarding-restart:${user.id}`, + 10, + 10 * 60 * 1000, + ); + if (!rl.allowed) { + annotate({ + action: { name: "onboarding.needs.restart" }, + meta: { outcome: "rate_limited" }, + }); + return apiError("Too many onboarding writes, try again later", 429, { + errorCode: "onboarding.restart.rateLimited", + }); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = onboardingRestartSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "onboarding.needs.restart" }, + meta: { outcome: "validation_failed" }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "onboarding.restart.invalid", + }); + } + + const steps = defaultOnboardingSteps(); + const written = await prisma.onboardingRecord.upsert({ + where: { userId: user.id }, + create: { userId: user.id, stepsJson: toJson(steps) }, + update: { + stepsJson: toJson(steps), + completedAt: null, + modulesDerivedAt: null, + }, + select: ONBOARDING_RECORD_SELECT, + }); + + await auditLog("onboarding.restart", { + userId: user.id, + ipAddress: getClientIp(request), + details: { steps: steps.length }, + }); + + annotate({ + action: { name: "onboarding.needs.restart" }, + meta: { outcome: "reset" }, + }); + + return apiSuccess({ onboarding: toOnboardingStateDto(written) }); +}); diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts index 2d7b3b66a..06a81dba6 100644 --- a/src/lib/openapi/routes/onboarding.ts +++ b/src/lib/openapi/routes/onboarding.ts @@ -23,13 +23,131 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; +import { + ONBOARDING_FIRST_RESULT_TASKS, + ONBOARDING_MEDICATION_ANSWERS, + ONBOARDING_RECORD_TARGETS, + ONBOARDING_SOURCE_KEYS, + ONBOARDING_STEP_IDS, + ONBOARDING_STEP_STATUSES, + ONBOARDING_VISIT_ANSWERS, +} from "@/lib/onboarding/needs"; import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; import { onboardingCompleteSchema, onboardingStepSchema, } from "@/lib/validations/onboarding"; +import { + onboardingAnswerSchema, + onboardingRestartSchema, +} from "@/lib/validations/onboarding-needs"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +/* ── v1.39 (C1): the needs-based flow ─────────────────────────────────────── */ + +const onboardingNeedsResource = z + .object({ + recordTarget: z + .enum(ONBOARDING_RECORD_TARGETS) + .nullable() + .describe("Q1 — whose record this is. Null until answered."), + areas: z + .array(z.enum(ONBOARDING_AREA_KEYS)) + .describe( + "Q2 — the areas to keep an eye on. Each one maps to at least one module through `ONBOARDING_AREA_MODULES`; the map is the server's, and a client must not re-derive it.", + ), + medication: z + .enum(ONBOARDING_MEDICATION_ANSWERS) + .nullable() + .describe("Q3 — medication on a schedule."), + sources: z + .array(z.enum(ONBOARDING_SOURCE_KEYS)) + .describe("Q4 — where the readings come from today."), + visit: z + .enum(ONBOARDING_VISIT_ANSWERS) + .nullable() + .describe("Q5 — a doctor's visit coming up."), + units: z + .object({ + glucoseUnit: z.enum(["mg/dL", "mmol/L"]).nullable(), + unitPreference: z.enum(["metric", "imperial"]).nullable(), + }) + .describe( + "Q6 — the unit answers as given. NOT the source of truth for display: `glucoseUnit` and `unitPreference` on the account payload are, and the answers step writes them. This records what the flow was told.", + ), + }) + .meta({ + id: "OnboardingNeeds", + description: + "The setup answers as given. Every field is null or empty until its question is answered.", + }); + +const onboardingStepResource = z + .object({ + id: z.enum(ONBOARDING_STEP_IDS), + status: z.enum(ONBOARDING_STEP_STATUSES), + }) + .meta({ + id: "OnboardingStepState", + description: + "One step of the setup flow. The ids are stable; a client that meets one it does not know SKIPS it rather than refusing the list, so the flow may grow a screen without a client release.", + }); + +const onboardingFirstResultResource = z + .object({ + task: z.enum(ONBOARDING_FIRST_RESULT_TASKS), + target: z + .string() + .nullable() + .describe( + "What the task is about — a source key for a connection, an area key for a reading. Null for the medication task.", + ), + completedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the offered task produced its result, or null."), + }) + .meta({ + id: "OnboardingFirstResult", + description: + "The one task the flow offered at the end, and whether it produced its result.", + }); + +export const onboardingStateResource = z + .object({ + steps: z.array(onboardingStepResource), + needs: onboardingNeedsResource, + completedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "When the needs flow was completed. Distinct from the account payload's `onboardingCompletedAt`, which stays the first-run redirect's gate: `POST /api/onboarding/restart` clears this one and leaves that one alone.", + ), + firstResult: onboardingFirstResultResource.nullable(), + }) + .meta({ + id: "OnboardingState", + description: + "The needs-based setup flow for one record. Always present on the account payload — a record that never entered the flow reads as empty answers with nine `pending` steps, so a client branches on the step statuses rather than on the field's existence.", + }); + +const onboardingStateEnvelope = dataEnvelope( + z.object({ onboarding: onboardingStateResource }), + "OnboardingStateEnvelope", +); + +const onboardingAnswerRequest = onboardingAnswerSchema.meta({ + id: "OnboardingAnswerRequest", + description: + "One step, answered or deliberately passed. The union has an arm per step id plus a skip arm for the six steps that may be passed — `who` is the one required question, and `confirm` / `done` are acknowledgements rather than questions, so none of the three has a skip arm. Every arm is strict: an unknown key is a 422, and a skip may never carry an answer.", +}); + +const onboardingRestartRequest = onboardingRestartSchema.meta({ + id: "OnboardingRestartRequest", + description: "No input beyond the session. Send `{}`.", +}); + const tourProgressResource = tourProgressSchema.meta({ id: "TourProgress", description: @@ -121,6 +239,84 @@ const onboardingCompleteRequest = onboardingCompleteSchema.meta({ }); export const onboardingPaths: NonNullable = { + "/api/onboarding/answers": { + patch: { + tags: ["Onboarding"], + summary: "Save one answer of the needs-based setup flow", + description: + "Persists one step of the setup questionnaire the moment it is answered, so leaving and returning resumes at the same step. The body names the step and carries either its answer or `status: \"skipped\"`; the two can never ride together.\n\nIdempotent. The stored state is computed by a pure function of the row and the body, so sending the same step twice writes the same value and the second write changes nothing a reader can see — including the first-result completion instant, which is kept from the existing row rather than re-taken. There is no ordering contract: steps may be answered, re-answered and passed in any order, which is what makes a back button work.\n\nThe `units` step is the one answer with a consequence outside this state: it writes the record's `glucoseUnit` and `unitPreference`, which every display surface already reads, and records the answer beside them.\n\nCookie or wildcard Bearer, and the CALLER's own record only: a request made while the session is acting on somebody else's record is refused with 403 `sharing.not_permitted` at every grant level, so a delegate can never write another record's setup. Rate-limited to 120 writes per 10 minutes per account.", + requestBody: { + required: true, + content: { "application/json": { schema: onboardingAnswerRequest } }, + }, + responses: { + "200": { + description: "The full setup state after the write.", + content: { + "application/json": { schema: onboardingStateEnvelope }, + }, + }, + "413": { + description: "Body exceeds 16 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`Content-Type` is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "The body did not validate. `meta.errorCode` = `onboarding.answers.invalid`, with every issue on the wire.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 120 answer writes in 10 minutes. `meta.errorCode` = `onboarding.answers.rateLimited`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/onboarding/restart": { + post: { + tags: ["Onboarding"], + summary: "Ask the setup questions again", + description: + "\"Set up again\", from Settings. Puts the nine steps back to `pending`, clears the flow's own completion stamp, and clears the derivation marker so the next `POST /api/onboarding/complete` may derive a module map again.\n\nIt writes no module state at all: the ordering-versus-removal decision says a re-run never turns off a module somebody turned on by hand, and the protection against the second derivation lives in the merge rather than here. It also keeps the answers, as the prefill for the re-run, keeps a first result that really happened, and leaves the account payload's `onboardingCompletedAt` alone so nobody is pushed back through the first-run redirect.\n\nCookie or wildcard Bearer, own record only — same refusal under a switch as the answers route. Rate-limited to 10 restarts per 10 minutes per account.", + requestBody: { + required: true, + content: { "application/json": { schema: onboardingRestartRequest } }, + }, + responses: { + "200": { + description: "The reset setup state.", + content: { + "application/json": { schema: onboardingStateEnvelope }, + }, + }, + "413": { + description: "Body exceeds 4 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "415": { + description: "`Content-Type` is not `application/json`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "The body carried a field. `meta.errorCode` = `onboarding.restart.invalid`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 10 restarts in 10 minutes. `meta.errorCode` = `onboarding.restart.rateLimited`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/onboarding/step": { post: { tags: ["Onboarding"], @@ -178,9 +374,9 @@ export const onboardingPaths: NonNullable = { "/api/onboarding/complete": { post: { tags: ["Onboarding"], - summary: "Stamp onboarding as complete (legacy single-shot path)", + summary: "Stamp onboarding as complete, and derive the module map", description: - "Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nThe older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it writes no audit row and enforces no rate limit of its own. It also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing call.\n\nCalling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.\n\nCookie or wildcard Bearer; `userId` is never read from the body.", + "Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nv1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record that answered the questions (Q1 is set), it derives that record's module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions again. The merge is one-directional — a module the answers name is switched on, a module they do not name is switched off only where the record does not already carry an explicit on. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route sets to `true` when the cycle area was chosen and never to `false`. The response then carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement it always did.\n\nThe older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it writes no audit row and enforces no rate limit of its own. It also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing call.\n\nCalling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409 `onboarding.step.completed`, because that route's guarded update requires the completion stamp to still be null.\n\nCookie or wildcard Bearer; `userId` is never read from the body.", requestBody: { required: true, content: { "application/json": { schema: onboardingCompleteRequest } }, @@ -188,11 +384,14 @@ export const onboardingPaths: NonNullable = { responses: { "200": { description: - "Onboarding stamped complete. The body is the fixed `{ completed: true }` acknowledgement — it does not echo what was saved.", + "Onboarding stamped complete. `completed` is the fixed acknowledgement — it does not echo the profile fields that were saved. `onboarding` is present only for a record that entered the needs-based flow, and carries the state after the derivation.", content: { "application/json": { schema: dataEnvelope( - z.object({ completed: z.literal(true) }), + z.object({ + completed: z.literal(true), + onboarding: onboardingStateResource.optional(), + }), "OnboardingCompleteEnvelope", ), }, diff --git a/src/proxy.ts b/src/proxy.ts index a43c6cb32..97a86c93a 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -158,6 +158,14 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = // Chart display-pref toggles above the charts (dashboard + insights): { path: "/api/dashboard/chart-overlay-prefs", method: "PUT" }, { path: "/api/dashboard/widgets", method: "PUT" }, + // v1.39 (C1) — the needs-based setup flow, which the demo has to be able + // to complete with preset answers (design spec §Principles 5). All three + // are user-scoped preference writes: closed enum answers, the step ledger, + // and the module map derived from them. No health data is created by any + // of them, and the demo account's own record is the only thing they touch. + { path: "/api/onboarding/answers", method: "PATCH" }, + { path: "/api/onboarding/complete", method: "POST" }, + { path: "/api/onboarding/restart", method: "POST" }, ]; // Legacy route redirects (German → English)