Retire the five-step wizard's route, goals and columns

The needs-based flow is the one setup flow now. POST /api/onboarding/step
and the six goal slugs go, and with them the two account columns the
wizard kept — its checkpoint and its goal list — through a migration
that drops them; onboarding_completed_at stays as the first-run
redirect's gate. The one durable effect the goals had, the dashboard
tile order, moves to the completion route: the answers now carry both
halves of Q2, which modules are on and what sits on top, and the seed
keeps the wizard's one contract of writing only while the layout column
is still unset.

The OpenAPI table loses the step path and the completion's description
says what the route does today; the demo-allowlist test names a
mutation that still exists.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 15:32:35 +02:00
1 parent b446911d7d
commit a062610895
16 files changed
+330 -1219

No files matched your search

+16 -164
View File
@@ -16315,85 +16315,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/onboarding/step:
post:
tags:
- Onboarding
summary: Advance the onboarding wizard by one step
description: >-
Persists progress through the four-step wizard. The contract is strictly ordered: the submitted `step` must be
exactly the stored step plus one, so a client cannot skip ahead or replay a step it already sent.
Submitting step 4 completes onboarding — the completion instant is stamped in the same write, the proxy-readable
pending cookie is cleared so the next navigation stops redirecting to `/onboarding`, and the stored goal
selection seeds the dashboard layout. That seed is ONE-TIME and conditional: it only runs while the layout
column is still unset, so a person who already arranged their tiles is never clobbered, and a concurrent layout
save that lands first wins.
The write is guarded on the state it validated, so two tabs submitting the same step do not both succeed —
exactly one lands and the other gets 409.
Rate-limited to 30 writes per 10 minutes per user, which is generous for a four-step flow and tight enough to
defang a stuck retry loop. Every accepted call writes an audit row. Cookie or wildcard Bearer; `userId` is never
read from the body.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardingStepRequest"
responses:
"200":
description: The stored step and completion stamp after the write.
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardingStepEnvelope"
"401": *a1
"404":
description: The session's user row no longer exists. `meta.errorCode` = `onboarding.user.notFound`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: "The write was refused and nothing changed. `meta.errorCode` distinguishes three cases:
`onboarding.step.completed` — onboarding is already finished, so the wizard has nothing left to advance;
`onboarding.step.outOfOrder` — the submitted step is not the stored step plus one, and the message names the
current step; `onboarding.step.concurrent` — the row moved between the check and the write, which is what a
second tab sees."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"413":
description: Body exceeds 64 KiB.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"415":
description: "`Content-Type` is not `application/json`."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: The body did not validate. `meta.errorCode` = `onboarding.step.invalid`. Single-message, not the
multi-issue envelope — the per-field detail is not on the wire.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429":
description: More than 30 onboarding writes in 10 minutes. `meta.errorCode` = `onboarding.step.rateLimited`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/onboarding/complete:
post:
tags:
@@ -16404,33 +16325,24 @@ paths:
cookie so the next navigation stops redirecting to `/onboarding`.
v1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record
that FINISHED the questions — every one of them answered or deliberately passed — 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`. A half-answered flow derives nothing and is
not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen
would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY
wizard's completion out of the derivation, since that wizard never puts these questions.
v1.39 — this is the needs-based flow's confirm endpoint. For a record that FINISHED the questions — every one of
them answered or deliberately passed — 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 and holds no data in that domain. 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`. In the same derivation the dashboard order is seeded from the
answers — the tiles of the chosen areas (and the medication tile for a schedule) move to the front and are made
visible — but only while the layout column is still unset, so a person who already arranged their tiles is never
clobbered. A half-answered flow derives nothing and is not stamped as complete.
The response then carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the
fixed acknowledgement it always did.
The 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
enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one
(`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. It
also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing
call.
Calling 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.
The response carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed
acknowledgement alone — it stamps the completion whatever the answers say, re-stamps on every call rather than
refusing a second one, and enforces no rate limit of its own. The legacy half writes no audit row; the needs
half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules
route audits.
Cookie or wildcard Bearer; `userId` is never read from the body.
@@ -28003,30 +27915,6 @@ components:
properties: {}
additionalProperties: false
description: No input beyond the session. Send `{}`.
OnboardingStepRequest:
type: object
properties:
step:
type: integer
minimum: 1
maximum: 4
goals:
maxItems: 6
type: array
items:
type: string
enum:
- weight-management
- bp-tracking
- glucose-tracking
- sleep-improvement
- medication-compliance
- general-wellness
required:
- step
description: The wizard step being COMPLETED, 1–4. It must equal the stored step plus one — the server does not clamp or
skip. `goals` rides the step-2 submit; every slug is checked against the closed set, so one unknown slug fails
the whole request rather than being dropped.
OnboardingCompleteRequest:
type: object
properties:
@@ -63476,42 +63364,6 @@ components:
- completedAt
additionalProperties: false
description: The one task the flow offered at the end, and whether it produced its result.
OnboardingStepEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/OnboardingStepResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
OnboardingStepResponse:
type: object
properties:
step:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: The stored step after the write.
onboardingCompletedAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: Non-null once step 4 landed.
required:
- step
- onboardingCompletedAt
additionalProperties: false
OnboardingCompleteEnvelope:
type: object
properties:
@@ -0,0 +1,11 @@
-- v1.39 (C2) — retire the five-step wizard's two columns.
--
-- `onboarding_step` held the wizard's checkpoint (0..4) and `onboarding_goals`
-- the six goal slugs whose only durable effect was a one-time dashboard seed.
-- The needs-based flow keeps its state on `onboarding_records` and seeds the
-- dashboard from the answers there, so nothing reads or writes either column
-- any more. `onboarding_completed_at` stays: it is the first-run redirect's
-- gate and the needs flow keeps stamping it.
ALTER TABLE "users"
DROP COLUMN IF EXISTS "onboarding_step",
DROP COLUMN IF EXISTS "onboarding_goals";
+31 -51
View File
@@ -385,29 +385,9 @@ model User {
// contract for every other self-hoster.
documentsAutoAiRead Boolean @default(false) @map("documents_auto_ai_read")
// Onboarding
// Onboarding. The first-run redirect's gate; the needs-based setup flow
// (`OnboardingRecord`) keeps its own ledger and stamps this on confirm.
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
// v1.4.25 W14b — last persisted onboarding wizard step.
// Encodes resume-from-where-you-left-off semantics for the rebuilt
// multi-step wizard at `/onboarding/[step]`. Values mirror the
// wizard's step indices:
// 0 = welcome
// 1 = goals
// 2 = source
// 3 = baseline
// 4 = done (triggers `onboardingCompletedAt` flip on the same
// round-trip — the column stays at 4 for completed users)
// Null = legacy pre-W14b user; the wizard treats null as 0 on mount.
onboardingStep Int @default(0) @map("onboarding_step")
// v1.17.1 — goal slugs chosen during onboarding (GoalsChipPicker).
// Seeds dashboard tile pinning + reminder suggestions; NOT a clinical
// target store (targets are fully derived from height/age/gender +
// clinical refs in /api/insights/targets). Empty [] = skipped / no
// preference. The slug set is the closed enum
// `ONBOARDING_GOAL_SLUGS` in src/lib/onboarding/goals.ts; a String[]
// (not a relation) mirrors `insightsExcludeMetrics` precedent — the
// set is small and fixed, never queried relationally.
onboardingGoals String[] @default([]) @map("onboarding_goals")
// v1.4.15 Phase B5: tracks whether the user has finished (or
// explicitly dismissed) the spotlight tour that overlays the
// dashboard on first arrival. Distinct from the wizard at
@@ -1000,7 +980,7 @@ model User {
// record. 1:1, and keyed by record rather than by the person at the
// keyboard so a guardian running a managed profile's setup writes the
// child's row, not their own. See `OnboardingRecord`.
onboardingRecord OnboardingRecord?
onboardingRecord OnboardingRecord?
// v1.15.20 — user-authored "about me" context for the AI surfaces
// (1:1, encrypted free text). See `UserHealthProfile`.
@@ -4713,14 +4693,14 @@ model AppSettings {
// id or an unreachable host all parse perfectly and deliver nothing.
/// Instant the last report left successfully. Consumer: the GlitchTip card's
/// badge, which is only green while this is inside the recent window.
glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
/// Instant the last attempt failed. Consumer: the GlitchTip card's status
/// line, which names the failure rather than showing a reassuring badge.
glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
/// Short classification of that failure (`invalid_dsn`, `http_403`,
/// `network`) — never a response body, which is a remote host's text.
/// Consumer: the same status line.
glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
// Medication reminder thresholds (minutes after windowEnd)
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
@@ -5340,7 +5320,7 @@ model MeasurementReminder {
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Back-relation for the appointment arm: a PLANNED `Encounter` mints
/// exactly one ENCOUNTER-origin row and points at it. SetNull on the far
/// side, so deleting the reminder leaves the visit intact.
@@ -5624,15 +5604,15 @@ model CustomMetric {
/// snapshot of the metric's unit at write time (historical truth, mirroring the
/// LabResult per-row unit). Charts read these LIVE (no rollup tier).
model CustomMetricEntry {
id String @id @default(cuid())
userId String @map("user_id")
customMetricId String @map("custom_metric_id")
id String @id @default(cuid())
userId String @map("user_id")
customMetricId String @map("custom_metric_id")
value Float
/// Unit snapshot from the metric at write time.
unit String
measuredAt DateTime @map("measured_at")
measuredAt DateTime @map("measured_at")
note String?
createdAt DateTime @default(now()) @map("created_at")
createdAt DateTime @default(now()) @map("created_at")
/// v1.37.20 — soft-delete tombstone, peer parity with Measurement /
/// MoodEntry (every other entry surface deletes recoverably with an undo
/// toast; custom-metric entries were the one hard delete). NULL = live.
@@ -6140,28 +6120,28 @@ model CoachReminder {
// must never land in the table as plaintext. A cleared profile keeps the
// row with a NULL payload so `updatedAt` documents the deletion instant.
model UserHealthProfile {
id String @id @default(cuid())
userId String @unique @map("user_id")
id String @id @default(cuid())
userId String @unique @map("user_id")
// Free text, hard-capped at 4 000 chars by the route's Zod schema
// BEFORE encryption. NULL = cleared / never written.
aboutMeEncrypted Bytes? @map("about_me_encrypted")
aboutMeEncrypted Bytes? @map("about_me_encrypted")
// v1.16.0 — structured self-context companions to the free text.
// Short answers (each capped at 500 chars BEFORE encryption).
// Age/gender deliberately do NOT live here — they come from the User
// profile and are merged into the prompt block at assembly time so
// the data is never duplicated.
conditionsEncrypted Bytes? @map("conditions_encrypted")
allergiesEncrypted Bytes? @map("allergies_encrypted")
coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
conditionsEncrypted Bytes? @map("conditions_encrypted")
allergiesEncrypted Bytes? @map("allergies_encrypted")
coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
// v1.16.0 — up to 3 clarifying questions the server derives after a
// save (AI-generated when a provider + budget allow, deterministic
// hints otherwise). Encrypted JSON string[]; the Coach composer
// renders them as tappable suggestion chips. NULL = none pending.
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
// Closed inclusion list for self-context sent to Coach and briefing prompts.
// The static default preserves every pre-existing prompt section for legacy
// rows without automatically opting future enum members in.
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
// Emergency ("Notfalldaten") facts, surfaced on page one of the doctor
// report and edited in Settings -> Anamnese. Three closed-set enums are
// plaintext (read directly by the report, like `User.gender`); three free
@@ -6169,14 +6149,14 @@ model UserHealthProfile {
// `*Encrypted` column. All six are nullable: an unset emergency profile is
// absent, not empty. The report page is withheld unless the EMERGENCY leaf is
// admitted AND at least one of these holds a value.
emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
organDonorStatus OrganDonorStatus? @map("organ_donor_status")
advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
organDonorStatus OrganDonorStatus? @map("organ_donor_status")
advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -8380,9 +8360,9 @@ model VaccinationDocumentLink {
/// covers free text a person wrote (`UserHealthProfile.aboutMeEncrypted`, the
/// Coach transcript, the emergency note) and identifiers (the KVNR), and an
/// answer drawn from nine fixed chips is neither. It is the same class of
/// value as `modulePreferencesJson` and `onboardingGoals`, both of which sit
/// in plaintext beside it. Readers are fail-soft rather than schema-validated
/// at the database edge — see `src/lib/onboarding/needs.ts`.
/// value as `modulePreferencesJson`, which sits in plaintext beside it.
/// Readers are fail-soft rather than schema-validated at the database edge —
/// see `src/lib/onboarding/needs.ts`.
model OnboardingRecord {
id String @id @default(cuid())
userId String @unique @map("user_id")
@@ -73,7 +73,7 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
expect(proxy(makeRequest("/api/onboarding/answers", "DELETE")).status).toBe(
403,
);
expect(proxy(makeRequest("/api/onboarding/step", "POST")).status).toBe(403);
expect(proxy(makeRequest("/api/onboarding/tour", "POST")).status).toBe(403);
});
it("still blocks a health-data mutation (POST /api/measurements)", () => {
@@ -1,399 +0,0 @@
/**
* v1.4.25 W21 Fix-M — concurrency regression for
* `POST /api/onboarding/step`.
*
* The step write was fetch-then-update: two parallel tabs both
* reading `onboardingStep = 2` and both POSTing `step: 3` would both
* succeed because the second write didn't re-check the precondition.
* The route now claims the row via `updateMany` with the precondition
* embedded in the WHERE clause, so exactly one update lands and the
* loser sees `count = 0` and returns 409.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../step/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/onboarding/step", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("POST /api/onboarding/step — happy path", () => {
it("advances 0 → 1 on the first wizard submission", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 0,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { step: number } };
expect(body.data.step).toBe(1);
});
it("marks completion on step 4 (sets onboardingCompletedAt)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-05-14T12:00:00Z"),
} as never);
const res = await POST(req({ step: 4 }));
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
expect(updateCall.data).toMatchObject({
onboardingStep: 4,
});
expect(updateCall.data).toHaveProperty("onboardingCompletedAt");
});
});
describe("POST /api/onboarding/step — guard rails", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(401);
});
it("returns 422 for an out-of-range step value", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ step: 99 }));
expect(res.status).toBe(422);
});
it("returns 409 when the user has already completed onboarding", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-05-01T12:00:00Z"),
} as never);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(409);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("returns 409 when the requested step is out of order", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
// The route sees current = 1; submitting step 3 (skipping 2) must
// be rejected before the write.
const res = await POST(req({ step: 3 }));
expect(res.status).toBe(409);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
describe("POST /api/onboarding/step — concurrent write race", () => {
it("returns 409 when the conditional update affects zero rows", async () => {
// Regression for Fix-M / code-M7: two parallel tabs both read
// `onboardingStep = 2`, both POST `step: 3`. The first lands;
// when the second runs the conditional updateMany, the WHERE
// clause no longer matches (the column moved to 3) and Prisma
// returns `count = 0`. The route must surface 409 so the client
// can refresh and retry instead of double-advancing the wizard.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await POST(req({ step: 3 }));
expect(res.status).toBe(409);
// findUniqueOrThrow must NOT be called when the claim fails —
// the route returns before re-reading the row.
expect(prisma.user.findUniqueOrThrow).not.toHaveBeenCalled();
});
it("includes the precondition in the updateMany WHERE clause", async () => {
// The whole point of the conditional update is that the WHERE
// clause re-asserts the precondition we read. Pin the contract
// so a future refactor can't quietly drop the guard.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never);
await POST(req({ step: 3 }));
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
.where as Record<string, unknown>;
expect(whereArg).toMatchObject({
id: "user-1",
onboardingCompletedAt: null,
});
// Strict-equality precondition — Migration 0060 dropped the
// legacy nullable path so every advance uses `{ in: [current] }`.
expect(whereArg.onboardingStep).toEqual({ in: [2] });
});
it("advances a step-0 row to step 1 using the strict-equality precondition", async () => {
// Migration 0060 (v1.4.25 W21 Fix-O) backfilled NULL → 0 and
// flipped `onboarding_step` to NOT NULL, so the conditional
// update always uses the strict-equality form — there is no
// legacy null branch left for the route to widen.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 0,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(200);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
.where as Record<string, unknown>;
expect(whereArg.onboardingStep).toEqual({ in: [0] });
});
});
describe("POST /api/onboarding/step — rate limit", () => {
it("returns 429 when the per-user rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
limit: 60,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(429);
});
});
describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => {
it("persists the selected goal slugs on the step-2 submit", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
const res = await POST(
req({ step: 2, goals: ["weight-management", "bp-tracking"] }),
);
expect(res.status).toBe(200);
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
expect(data).toMatchObject({
onboardingStep: 2,
onboardingGoals: ["weight-management", "bp-tracking"],
});
});
it("persists an explicit empty array on skip (no preference)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
await POST(req({ step: 2, goals: [] }));
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
expect(data).toMatchObject({ onboardingGoals: [] });
});
it("rejects an unknown goal slug with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] }));
expect(res.status).toBe(422);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("does NOT write onboardingGoals when the field is omitted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
await POST(req({ step: 2 }));
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
expect(data).not.toHaveProperty("onboardingGoals");
});
});
describe("POST /api/onboarding/step — dashboard seeding on completion", () => {
it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// 1st findUnique = the fresh step-read; 2nd = the seed-read.
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never)
.mockResolvedValueOnce({
onboardingGoals: ["glucose-tracking"],
dashboardWidgetsJson: null,
} as never);
// 1st updateMany = the step claim; 2nd = the gated seed write.
vi.mocked(prisma.user.updateMany)
.mockResolvedValueOnce({ count: 1 } as never)
.mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
} as never);
const res = await POST(req({ step: 4 }));
expect(res.status).toBe(200);
// The second updateMany is the seed; its WHERE pins the null gate
// and its data carries a dashboard layout.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(2);
const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0];
expect(seedCall.data).toHaveProperty("dashboardWidgetsJson");
expect(seedCall.where).toHaveProperty("dashboardWidgetsJson");
});
it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never)
.mockResolvedValueOnce({
onboardingGoals: ["glucose-tracking"],
dashboardWidgetsJson: { version: 1, widgets: [] },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
} as never);
const res = await POST(req({ step: 4 }));
expect(res.status).toBe(200);
// Only the step-claim updateMany fired — the seed was gated out
// because the user already has a layout.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
});
it("does NOT seed when stored goals are empty (no preference)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never)
.mockResolvedValueOnce({
onboardingGoals: [],
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
} as never);
await POST(req({ step: 4 }));
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
});
});
+39 -15
View File
@@ -1,4 +1,5 @@
import { prisma, toJson } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -17,6 +18,7 @@ import {
mergeDerivedModulePreferences,
OWNED_MODULE_KEYS,
} from "@/lib/modules/registry";
import { buildNeedsSeededDashboardLayout } from "@/lib/onboarding/dashboard-seed";
import {
everyOnboardingQuestionSettled,
readHeldUnitPreferences,
@@ -37,22 +39,24 @@ 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 FINISHED the questions —
* every one of them answered or deliberately passed — and is what turns them
* 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.
* v1.39 — the confirm screen's endpoint for the needs-based flow, and the
* one completion write there is. The stamp half is what it always was: the
* optional profile fields, the completion stamp on
* `User.onboardingCompletedAt`, and the cleared pending cookie — the welcome
* screen's "skip for now" is this route with an empty body. The needs half
* runs only for a record that actually FINISHED the questions — every one
* of them answered or deliberately passed — and is what turns them into a
* module map and a dashboard order — 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.
* v1.39 (C2) — the dashboard order joins the derivation. The five-step
* wizard's goal slugs used to seed it from their own route; the answers now
* carry both halves of Q2 — which modules are on, and what is on top — and
* the seed keeps the wizard's one contract: only while the layout column is
* still unset.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -166,6 +170,7 @@ async function completeNeedsFlow(
}
let derived = false;
let dashboardSeeded = false;
let keptForData: string[] = [];
if (record.modulesDerivedAt === null) {
const defaults = deriveOnboardingModuleDefaults({
@@ -230,6 +235,24 @@ async function completeNeedsFlow(
keptForData = [...holdsData];
derived = true;
// The ordering half of Q2. ONE-TIME and conditional: `updateMany` carries
// the `dashboardWidgetsJson IS NULL` precondition in its WHERE, so a
// layout somebody already arranged — or a concurrent layout save that
// lands first — leaves `count = 0` and the seed is skipped rather than
// overwriting it. An answer set that speaks to no tile builds `null` and
// writes nothing, which leaves the default layout in place.
const seededLayout = buildNeedsSeededDashboardLayout(state.needs);
if (seededLayout) {
const seeded = await prisma.user.updateMany({
where: {
id: userId,
dashboardWidgetsJson: { equals: Prisma.JsonNull },
},
data: { dashboardWidgetsJson: toJson(seededLayout) },
});
dashboardSeeded = seeded.count === 1;
}
}
const now = new Date();
@@ -256,6 +279,7 @@ async function completeNeedsFlow(
meta: {
outcome: derived ? "derived" : "already_derived",
keptForData: keptForData.length,
dashboard_seeded: dashboardSeeded,
},
});
-244
View File
@@ -1,244 +0,0 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
apiValidationError,
getClientIp,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { prisma, toJson } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { buildGoalSeededDashboardLayout } from "@/lib/onboarding/goals";
import { onboardingStepSchema } from "@/lib/validations/onboarding";
/**
* 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.
*/
// v1.17.1 — the optional goal slugs from the GoalsChipPicker (step 2 submit)
// ride the same body. Validated against the closed slug enum so an unknown
// slug 422s; persisted to `User.onboardingGoals` field-by-field (no mass
// assignment) and, on completion (step 4), used to seed the dashboard layout
// when the user never customized it. Omitted leaves the stored goals
// untouched. The schema itself lives in `@/lib/validations/onboarding` so the
// OpenAPI registry reads the same object this parse does.
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, {
errorCode: "onboarding.step.rateLimited",
});
}
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = onboardingStepSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "validation_failed" },
});
return apiValidationError(
"Invalid step payload",
sanitiseZodIssues(parsed.error.issues),
422,
{
errorCode: "onboarding.step.invalid",
},
);
}
const { step, goals } = 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, {
errorCode: "onboarding.user.notFound",
});
}
if (fresh.onboardingCompletedAt) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "already_completed" },
});
return apiError("Onboarding already completed", 409, {
errorCode: "onboarding.step.completed",
});
}
const current = fresh.onboardingStep;
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, {
errorCode: "onboarding.step.outOfOrder",
});
}
const completing = step === 4;
// Conditional update — the WHERE clause re-asserts the
// precondition we read above so the write can only land on a row
// whose state still matches what we validated. Two parallel tabs
// both reading `onboardingStep = 2` and both POSTing `step: 3`
// would otherwise both succeed (the read-then-write race); with
// this guard exactly one update sees `count = 1`, the second sees
// `count = 0` and returns 409. `onboardingCompletedAt: null` also
// catches a race with `POST /api/onboarding/complete`. Migration
// 0060 (v1.4.25 W21) backfilled NULL → 0 and flipped the column
// to NOT NULL, so the legacy null branch this code carried before
// is dropped — every row now matches the strict-equality form.
const claimed = await prisma.user.updateMany({
where: {
id: user.id,
onboardingCompletedAt: null,
onboardingStep: { in: [current] },
},
// Field-by-field assembly — no mass assignment. `goals` only
// lands when the client sent it (the step-2 submit); it is already
// validated against the closed slug enum by the Zod schema, so the
// array reaching Prisma can never contain an out-of-set value.
data: {
onboardingStep: step,
...(completing ? { onboardingCompletedAt: new Date() } : {}),
...(goals !== undefined ? { onboardingGoals: goals } : {}),
},
});
if (claimed.count !== 1) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "concurrent_write", current, requested: step },
});
return apiError(
"Onboarding step changed concurrently, refresh and retry",
409,
{ errorCode: "onboarding.step.concurrent" },
);
}
const updated = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: {
onboardingStep: true,
onboardingCompletedAt: true,
},
});
let goalsSeeded = false;
if (completing) {
// Mirror /api/onboarding/complete — clear the proxy-readable
// pending cookie so the next navigation drops the /onboarding
// redirect immediately.
await setOnboardingPendingCookie(false);
// v1.17.1 — seed the dashboard from the stored goal selection.
// ONE-TIME, gated on `dashboardWidgetsJson == null` so a user who
// already arranged tiles is never clobbered. The seed promotes the
// goal-mapped tiles to the top and forces them visible; an empty /
// general-wellness-only selection produces null and leaves the
// column untouched (default layout). The resulting layout is what
// both the web dashboard and the iOS widgets contract read —
// server-authoritative, no client recompute.
const seedRow = await prisma.user.findUnique({
where: { id: user.id },
select: { onboardingGoals: true, dashboardWidgetsJson: true },
});
if (seedRow && seedRow.dashboardWidgetsJson == null) {
const seededLayout = buildGoalSeededDashboardLayout(
seedRow.onboardingGoals,
);
if (seededLayout) {
// updateMany so the `dashboardWidgetsJson: null` precondition
// rides in the WHERE clause — a concurrent Settings → Dashboard
// save that lands first leaves `count = 0` and the seed is
// skipped rather than overwriting the user's fresh layout.
const seeded = await prisma.user.updateMany({
where: {
id: user.id,
dashboardWidgetsJson: { equals: Prisma.JsonNull },
},
data: { dashboardWidgetsJson: toJson(seededLayout) },
});
goalsSeeded = seeded.count === 1;
}
}
}
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,
...(goals !== undefined ? { goals_count: goals.length } : {}),
...(completing ? { goals_seeded: goalsSeeded } : {}),
},
});
return apiSuccess({
step: updated.onboardingStep,
onboardingCompletedAt: updated.onboardingCompletedAt,
});
});
-2
View File
@@ -410,8 +410,6 @@ export const USER_RESET = {
// Back to the start of the tour — the record it was built around is gone
onboardingCompletedAt: null,
onboardingStep: 0,
onboardingGoals: [],
onboardingTourCompleted: false,
onboardingTourProgressJson: Prisma.DbNull,
} satisfies Prisma.UserUpdateInput;
@@ -0,0 +1,102 @@
/**
* v1.39 (C2) — the dashboard order the setup answers imply.
*
* The ordering half of Q2. Pins that every area maps only to tiles the
* layout knows, that the seed promotes and forces visible without dropping a
* tile, and that answers which speak to no tile seed nothing at all.
*/
import { describe, expect, it } from "vitest";
import {
DEFAULT_DASHBOARD_LAYOUT,
type DashboardWidgetId,
} from "@/lib/dashboard-layout";
import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry";
import {
AREA_WIDGET_SEED_MAP,
buildNeedsSeededDashboardLayout,
promotedWidgetsFor,
} from "../dashboard-seed";
describe("AREA_WIDGET_SEED_MAP", () => {
it("names every area, and only tiles the layout actually knows", () => {
const known = new Set<DashboardWidgetId>(
DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId),
);
for (const area of ONBOARDING_AREA_KEYS) {
expect(AREA_WIDGET_SEED_MAP[area]).toBeDefined();
for (const id of AREA_WIDGET_SEED_MAP[area]) {
expect(known.has(id), `${area} → ${id}`).toBe(true);
}
}
});
});
describe("buildNeedsSeededDashboardLayout", () => {
it("seeds nothing for answers that speak to no tile", () => {
expect(
buildNeedsSeededDashboardLayout({ areas: [], medication: null }),
).toBeNull();
expect(
buildNeedsSeededDashboardLayout({
areas: ["labs", "illness", "cycle"],
medication: "no",
}),
).toBeNull();
});
it("promotes glucose to the top and forces it visible", () => {
const layout = buildNeedsSeededDashboardLayout({
areas: ["glucose"],
medication: null,
});
expect(layout).not.toBeNull();
expect(layout!.widgets.find((w) => w.id === "glucose")).toMatchObject({
order: 0,
visible: true,
tileVisible: true,
});
});
it("promotes every tile of a multi-area answer, and the medication tile for a schedule", () => {
const layout = buildNeedsSeededDashboardLayout({
areas: ["weight-body", "blood-pressure"],
medication: "sometimes",
});
const promoted = new Set(
promotedWidgetsFor({
areas: ["weight-body", "blood-pressure"],
medication: "sometimes",
}),
);
expect(promoted).toEqual(
new Set([
"weight",
"bodyFat",
"bp",
"bpInTarget",
"pulse",
"medications",
]),
);
for (const id of promoted) {
const w = layout!.widgets.find((x) => x.id === id);
expect(w?.visible).toBe(true);
expect(w?.tileVisible).toBe(true);
expect(w!.order).toBeLessThan(promoted.size);
}
});
it("keeps a dense, complete widget set", () => {
const layout = buildNeedsSeededDashboardLayout({
areas: ["sleep"],
medication: "no",
});
expect(layout!.widgets.length).toBe(
DEFAULT_DASHBOARD_LAYOUT.widgets.length,
);
const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b);
expect(orders).toEqual(orders.map((_, i) => i));
});
});
-104
View File
@@ -1,104 +0,0 @@
/**
* v1.17.1 — onboarding goal slug enum + dashboard seeding.
*
* Goals are a personalization seed, not a clinical target store: the
* only leverage they have is which dashboard tiles get promoted and
* made visible. These tests pin (a) the closed slug guard and (b) the
* one-time seed builder's promote/forces-visible/no-op contracts.
*/
import { describe, it, expect } from "vitest";
import {
ONBOARDING_GOAL_SLUGS,
GOAL_WIDGET_SEED_MAP,
isOnboardingGoalSlug,
buildGoalSeededDashboardLayout,
} from "../goals";
import {
DEFAULT_DASHBOARD_LAYOUT,
type DashboardWidgetId,
} from "@/lib/dashboard-layout";
describe("isOnboardingGoalSlug", () => {
it("accepts every member of the closed slug set", () => {
for (const slug of ONBOARDING_GOAL_SLUGS) {
expect(isOnboardingGoalSlug(slug)).toBe(true);
}
});
it("rejects unknown / non-string values", () => {
expect(isOnboardingGoalSlug("not-a-goal")).toBe(false);
expect(isOnboardingGoalSlug("")).toBe(false);
expect(isOnboardingGoalSlug(null)).toBe(false);
expect(isOnboardingGoalSlug(42)).toBe(false);
});
});
describe("GOAL_WIDGET_SEED_MAP", () => {
it("only maps to ids the dashboard layout actually knows", () => {
const known = new Set<DashboardWidgetId>(
DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => w.id as DashboardWidgetId),
);
for (const ids of Object.values(GOAL_WIDGET_SEED_MAP)) {
for (const id of ids) expect(known.has(id)).toBe(true);
}
});
it("leaves general-wellness without a tile preference", () => {
expect(GOAL_WIDGET_SEED_MAP["general-wellness"]).toEqual([]);
});
});
describe("buildGoalSeededDashboardLayout", () => {
it("returns null for an empty selection (no seed)", () => {
expect(buildGoalSeededDashboardLayout([])).toBeNull();
});
it("returns null for a general-wellness-only selection", () => {
expect(buildGoalSeededDashboardLayout(["general-wellness"])).toBeNull();
});
it("ignores unknown slugs and seeds nothing when only unknowns are passed", () => {
expect(buildGoalSeededDashboardLayout(["bogus", "nope"])).toBeNull();
});
it("promotes glucose to the top and forces it visible", () => {
const layout = buildGoalSeededDashboardLayout(["glucose-tracking"]);
expect(layout).not.toBeNull();
const widgets = layout!.widgets;
// glucose is default-invisible + ordered low; the seed lifts it to
// order 0 and turns both surfaces on.
const glucose = widgets.find((w) => w.id === "glucose");
expect(glucose).toMatchObject({
order: 0,
visible: true,
tileVisible: true,
});
});
it("promotes every mapped tile for a multi-goal selection", () => {
const layout = buildGoalSeededDashboardLayout([
"weight-management",
"bp-tracking",
]);
expect(layout).not.toBeNull();
const widgets = layout!.widgets;
const promotedIds = new Set(["weight", "bodyFat", "bp", "bpInTarget"]);
// Every promoted id sits in the leading block and is visible.
for (const id of promotedIds) {
const w = widgets.find((x) => x.id === id);
expect(w?.visible).toBe(true);
expect(w?.tileVisible).toBe(true);
expect(w!.order).toBeLessThan(promotedIds.size);
}
});
it("keeps a dense, complete widget set (no tiles dropped)", () => {
const layout = buildGoalSeededDashboardLayout(["sleep-improvement"]);
expect(layout!.widgets.length).toBe(
DEFAULT_DASHBOARD_LAYOUT.widgets.length,
);
const orders = layout!.widgets.map((w) => w.order).sort((a, b) => a - b);
expect(orders).toEqual(orders.map((_, i) => i));
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* v1.39 (C2) — the dashboard tile order the setup answers imply.
*
* "Enables the matching modules and orders the dashboard" (design spec §The
* questions, Q2): the module half lives in the registry's derivation, this
* is the ordering half. Each area names the tiles it is about; the seed
* promotes them to the top of the default layout and forces them visible on
* both surfaces, and leaves every other tile exactly where the default puts
* it. A daily medication schedule promotes the medication tile the same way.
*
* ONE-TIME, and the caller gates it: the completion route writes the seed
* only while `User.dashboardWidgetsJson` is still unset, so a person who
* already arranged their tiles is never clobbered, and a concurrent layout
* save that lands first wins. Not a clinical target store — targets are
* derived elsewhere; this only decides what is on top.
*
* Replaces the six goal slugs of the five-step wizard, whose only durable
* effect was this same seed.
*/
import {
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
type DashboardLayout,
type DashboardWidgetId,
} from "@/lib/dashboard-layout";
import type { OnboardingAreaKey } from "@/lib/modules/registry";
import type { OnboardingNeeds } from "./needs";
/**
* Area → the tiles to promote. An area whose surface is not a dashboard tile
* (cycle, labs, illness) maps to nothing; its module is still switched on.
* Ids must be members of `DASHBOARD_WIDGET_IDS`; the seed never invents one.
*/
export const AREA_WIDGET_SEED_MAP: Readonly<
Record<OnboardingAreaKey, readonly DashboardWidgetId[]>
> = Object.freeze({
"blood-pressure": ["bp", "bpInTarget", "pulse"],
"weight-body": ["weight", "bodyFat"],
glucose: ["glucose"],
sleep: ["sleep"],
mood: ["mood"],
cycle: [],
activity: ["steps"],
labs: [],
illness: [],
});
/** Q3 "yes" or "sometimes" promotes the medication tile. */
export const MEDICATION_WIDGET_SEED: readonly DashboardWidgetId[] = [
"medications",
];
/**
* The tiles the answers promote, in the default layout's order. Empty when
* the answers speak to no tile, which is the "leave the column unset" case.
*/
export function promotedWidgetsFor(
needs: Pick<OnboardingNeeds, "areas" | "medication">,
): DashboardWidgetId[] {
const promoted = new Set<DashboardWidgetId>();
for (const area of needs.areas) {
for (const id of AREA_WIDGET_SEED_MAP[area] ?? []) promoted.add(id);
}
if (needs.medication === "yes" || needs.medication === "sometimes") {
for (const id of MEDICATION_WIDGET_SEED) promoted.add(id);
}
return DEFAULT_DASHBOARD_LAYOUT.widgets
.map((w) => w.id as DashboardWidgetId)
.filter((id) => promoted.has(id));
}
/**
* Build the seeded layout, or `null` when there is nothing to promote so the
* caller can skip the write entirely and leave the column unset (the default
* layout). Every tile keeps its default config; the promoted ones move to the
* front, in their default relative order, and are forced visible on both
* surfaces. The result is dense: `order` is 0..n-1 with no tile dropped.
*/
export function buildNeedsSeededDashboardLayout(
needs: Pick<OnboardingNeeds, "areas" | "medication">,
): DashboardLayout | null {
const promoted = new Set(promotedWidgetsFor(needs));
if (promoted.size === 0) return null;
const base = DEFAULT_DASHBOARD_LAYOUT.widgets;
const front = base.filter((w) => promoted.has(w.id as DashboardWidgetId));
const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId));
const ordered = [...front, ...rest].map((w, index) => ({
...w,
...(promoted.has(w.id as DashboardWidgetId)
? { visible: true, tileVisible: true }
: {}),
order: index,
}));
return serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
widgets: ordered,
});
}
-112
View File
@@ -1,112 +0,0 @@
/**
* v1.17.1 — onboarding goal slugs + dashboard seeding.
*
* The closed set of goal slugs the GoalsChipPicker offers, plus the
* server-authoritative seeding that turns a goal selection into a
* personalization signal: which dashboard tiles get promoted to the
* top and made visible. Goals are NOT a clinical target store — the
* targets surface (`/api/insights/targets`) is fully derived from
* height/age/gender + clinical references. Their only leverage is the
* one-time dashboard tile pinning below, applied by
* `/api/onboarding/step` while the layout is still unset. Nothing else
* reads `User.onboardingGoals` — the column is collected, seeded from
* once, and then inert.
*
* One home for the slug enum so the API validation
* (`/api/onboarding/step`), the client picker, and the seeding all
* read the same source — same-key drift was the v1.4.16 dashboard-enum
* bug class.
*/
import {
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
type DashboardLayout,
type DashboardWidgetId,
} from "@/lib/dashboard-layout";
/** Closed slug set — mirrors `OnboardingGoalSlug` in GoalsChipPicker. */
export const ONBOARDING_GOAL_SLUGS = [
"weight-management",
"bp-tracking",
"glucose-tracking",
"sleep-improvement",
"medication-compliance",
"general-wellness",
] as const;
export type OnboardingGoalSlug = (typeof ONBOARDING_GOAL_SLUGS)[number];
export function isOnboardingGoalSlug(
value: unknown,
): value is OnboardingGoalSlug {
return (
typeof value === "string" &&
(ONBOARDING_GOAL_SLUGS as readonly string[]).includes(value)
);
}
/**
* Slug → dashboard widget ids to promote. `general-wellness` maps to
* nothing — it carries no tile preference, so a general-wellness-only
* selection leaves the default layout untouched. Ids must be members
* of `DASHBOARD_WIDGET_IDS`; the seed never invents an id.
*/
export const GOAL_WIDGET_SEED_MAP: Record<
OnboardingGoalSlug,
readonly DashboardWidgetId[]
> = {
"weight-management": ["weight", "bodyFat"],
"bp-tracking": ["bp", "bpInTarget"],
"glucose-tracking": ["glucose"],
"sleep-improvement": ["sleep"],
"medication-compliance": ["medications"],
"general-wellness": [],
};
/**
* Build the seeded dashboard layout for a goal selection.
*
* Returns a layout where the goal-mapped tiles are promoted to the top
* of the order (preserving their relative order within the default
* layout) and forced visible on both surfaces; every other tile keeps
* its default config, shifted down. Returns `null` when there is
* nothing to promote (empty selection or general-wellness only) so the
* caller can skip the write entirely and leave the column null →
* default layout.
*
* This is a ONE-TIME seed: the caller MUST gate it on
* `User.dashboardWidgetsJson == null` so it never clobbers a user who
* already arranged tiles.
*/
export function buildGoalSeededDashboardLayout(
goals: readonly string[] | null | undefined,
): DashboardLayout | null {
if (!goals || goals.length === 0) return null;
const promoted = new Set<DashboardWidgetId>();
for (const slug of goals) {
if (!isOnboardingGoalSlug(slug)) continue;
for (const id of GOAL_WIDGET_SEED_MAP[slug]) promoted.add(id);
}
if (promoted.size === 0) return null;
const base = DEFAULT_DASHBOARD_LAYOUT.widgets;
const promotedWidgets = base.filter((w) =>
promoted.has(w.id as DashboardWidgetId),
);
const rest = base.filter((w) => !promoted.has(w.id as DashboardWidgetId));
const ordered = [...promotedWidgets, ...rest].map((w, index) => ({
...w,
// Force the goal-mapped tiles visible on both surfaces; leave the
// rest exactly as the default layout had them.
...(promoted.has(w.id as DashboardWidgetId)
? { visible: true, tileVisible: true }
: {}),
order: index,
}));
return serializeDashboardLayout({
...DEFAULT_DASHBOARD_LAYOUT,
widgets: ordered,
});
}
+9 -11
View File
@@ -12,18 +12,16 @@
* pulls it in without dragging the server graph along, and the browser reads
* the same step ids the server writes.
*
* ── Coexistence with the wizard of today ───────────────────────────────────
* ── The completion stamp ───────────────────────────────────────────────────
*
* Nothing here retires anything. `User.onboardingStep`, `User.onboardingGoals`
* and the six goal slugs in `./goals.ts` keep their meaning and keep driving
* the four-step wizard and its one-time dashboard seed until C2 replaces that
* surface. The two states are disjoint by construction: the goal slugs decide
* TILE ORDER, these answers decide WHICH MODULES ARE ON, and neither reads the
* other. `User.onboardingCompletedAt` also stays what it was — the first-run
* redirect's gate — which is why the needs flow carries a completion stamp of
* its own rather than borrowing that column: "set up again" has to be able to
* re-ask the questions without pushing the person back through the first-run
* redirect.
* `User.onboardingCompletedAt` stays what it was — the first-run redirect's
* gate — which is why the needs flow carries a completion stamp of its own
* rather than borrowing that column: "set up again" has to be able to re-ask
* the questions without pushing the person back through the first-run
* redirect. The five-step wizard that used to share this state is gone (C2);
* its step counter and goal slugs left the account row with it, and the
* dashboard order those slugs seeded now comes from the answers here
* (`./dashboard-seed.ts`).
*/
import type {
OnboardingAreaKey,
+10 -86
View File
@@ -1,19 +1,16 @@
/**
* OpenAPI route table — the onboarding wizard and the module tour.
* OpenAPI route table — the setup flow and the module tour.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* The request schemas come from `src/lib/onboarding/tour-progress.ts` and
* `src/lib/validations/onboarding.ts` so the wire contract stays
* The request schemas come from `src/lib/onboarding/tour-progress.ts`,
* `src/lib/validations/onboarding.ts` and
* `src/lib/validations/onboarding-needs.ts` so the wire contract stays
* single-source with the runtime parsers.
*
* Two generations of the same flow live side by side here, and a client
* should pick one. `POST /api/onboarding/step` is the current wizard: four
* ordered checkpoints, the fourth of which completes. `POST
* /api/onboarding/complete` is the older single-shot path that stamps the
* completion and takes whatever profile fields it was given. They write the
* same column and refuse to co-operate — the step route's conditional update
* requires `onboardingCompletedAt: null`, so a `complete` call mid-wizard
* makes every remaining step 409.
* v1.39 — one flow. The needs-based setup (`answers` → `complete` →
* `restart`) replaced the four-checkpoint wizard, whose `POST
* /api/onboarding/step` is gone with it; `complete` is the one completion
* write, and it now also seeds the dashboard order from the answers.
*
* v1.18.6 — the resumable module-tour contract the iOS client mirrors:
* a fire-and-forget progress checkpoint plus the coarse completion
@@ -34,10 +31,7 @@ import {
} 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 { onboardingCompleteSchema } from "@/lib/validations/onboarding";
import {
onboardingAnswerSchema,
onboardingRestartSchema,
@@ -216,22 +210,6 @@ const disclaimerAckResponse = z
description: "The persisted disclaimer acknowledgment version.",
});
const onboardingStepRequest = onboardingStepSchema.meta({
id: "OnboardingStepRequest",
description:
"The wizard step being COMPLETED, 1–4. It must equal the stored step plus one — the server does not clamp or skip. `goals` rides the step-2 submit; every slug is checked against the closed set, so one unknown slug fails the whole request rather than being dropped.",
});
const onboardingStepResponse = z
.object({
step: z.number().int().describe("The stored step after the write."),
onboardingCompletedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe("Non-null once step 4 landed."),
})
.meta({ id: "OnboardingStepResponse" });
const onboardingCompleteRequest = onboardingCompleteSchema.meta({
id: "OnboardingCompleteRequest",
description:
@@ -319,66 +297,12 @@ export const onboardingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/onboarding/step": {
post: {
tags: ["Onboarding"],
summary: "Advance the onboarding wizard by one step",
description:
"Persists progress through the four-step wizard. The contract is strictly ordered: the submitted `step` must be exactly the stored step plus one, so a client cannot skip ahead or replay a step it already sent.\n\nSubmitting step 4 completes onboarding — the completion instant is stamped in the same write, the proxy-readable pending cookie is cleared so the next navigation stops redirecting to `/onboarding`, and the stored goal selection seeds the dashboard layout. That seed is ONE-TIME and conditional: it only runs while the layout column is still unset, so a person who already arranged their tiles is never clobbered, and a concurrent layout save that lands first wins.\n\nThe write is guarded on the state it validated, so two tabs submitting the same step do not both succeed — exactly one lands and the other gets 409.\n\nRate-limited to 30 writes per 10 minutes per user, which is generous for a four-step flow and tight enough to defang a stuck retry loop. Every accepted call writes an audit row. Cookie or wildcard Bearer; `userId` is never read from the body.",
requestBody: {
required: true,
content: { "application/json": { schema: onboardingStepRequest } },
},
responses: {
"200": {
description: "The stored step and completion stamp after the write.",
content: {
"application/json": {
schema: dataEnvelope(
onboardingStepResponse,
"OnboardingStepEnvelope",
),
},
},
},
"404": {
description:
"The session's user row no longer exists. `meta.errorCode` = `onboarding.user.notFound`.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"The write was refused and nothing changed. `meta.errorCode` distinguishes three cases: `onboarding.step.completed` — onboarding is already finished, so the wizard has nothing left to advance; `onboarding.step.outOfOrder` — the submitted step is not the stored step plus one, and the message names the current step; `onboarding.step.concurrent` — the row moved between the check and the write, which is what a second tab sees.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Body exceeds 64 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.step.invalid`. Single-message, not the multi-issue envelope — the per-field detail is not on the wire.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description:
"More than 30 onboarding writes in 10 minutes. `meta.errorCode` = `onboarding.step.rateLimited`.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/onboarding/complete": {
post: {
tags: ["Onboarding"],
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\nv1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record that FINISHED the questions — every one of them answered or deliberately passed — 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`. A half-answered flow derives nothing and is not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY wizard's completion out of the derivation, since that wizard never puts these questions.\n\nThe 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 enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. 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 the needs-based flow's confirm endpoint. For a record that FINISHED the questions — every one of them answered or deliberately passed — 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 and holds no data in that domain. 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`. In the same derivation the dashboard order is seeded from the answers — the tiles of the chosen areas (and the medication tile for a schedule) move to the front and are made visible — but only while the layout column is still unset, so a person who already arranged their tiles is never clobbered. A half-answered flow derives nothing and is not stamped as complete.\n\nThe response carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement alone — it stamps the completion whatever the answers say, re-stamps on every call rather than refusing a second one, and enforces no rate limit of its own. The legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits.\n\nCookie or wildcard Bearer; `userId` is never read from the body.",
requestBody: {
required: true,
content: { "application/json": { schema: onboardingCompleteRequest } },
+9 -29
View File
@@ -1,46 +1,26 @@
/**
* Request schemas for the two onboarding write endpoints.
* Request schema for the completion write.
*
* They live outside the route files for the same reason
* It lives outside the route file for the same reason
* `src/lib/validations/about-me.ts` does: a route module may only export
* handlers plus the Next.js route config, so the OpenAPI registry cannot
* import a schema declared inside one. Keeping the shapes here lets the
* import a schema declared inside one. Keeping the shape here lets the
* published contract and the runtime parser be the same object rather than
* two hand-kept copies.
*
* Prisma-free by construction — `@/lib/onboarding/goals` is pure data — so
* the generator script can pull them in without dragging the server graph
* along.
* Prisma-free by construction, so the generator script can pull it in
* without dragging the server graph along.
*/
import { z } from "zod/v4";
import { ONBOARDING_GOAL_SLUGS } from "@/lib/onboarding/goals";
/**
* `POST /api/onboarding/step` — the step-by-step wizard checkpoint.
*
* `step` is the step being COMPLETED and must equal the stored step plus one;
* the route refuses anything else with 409 rather than clamping. `goals`
* rides the step-2 submit and is validated against the closed slug set, so an
* unknown slug fails the whole request instead of being silently dropped.
*/
export const onboardingStepSchema = z.object({
step: z.number().int().min(1).max(4),
goals: z
.array(z.enum(ONBOARDING_GOAL_SLUGS))
.max(ONBOARDING_GOAL_SLUGS.length)
.optional(),
});
export type OnboardingStepInput = z.infer<typeof onboardingStepSchema>;
/**
* `POST /api/onboarding/complete` — the legacy single-shot completion path.
* `POST /api/onboarding/complete` — the completion stamp.
*
* Every field is optional: the endpoint's job is the completion stamp, and
* the profile fields are whatever the wizard happened to collect. A field
* the profile fields are whatever the caller happened to collect. A field
* that parses but is falsy (an empty `displayName`, `heightCm: 0`) is not
* written — the route only assigns truthy values.
* written — the route only assigns truthy values. The web flow sends an
* empty body and writes its profile through `PUT /api/auth/profile`.
*/
export const onboardingCompleteSchema = z.object({
displayName: z.string().trim().min(1).max(50).optional(),
@@ -104,7 +104,6 @@ describe("DELETE /api/settings/data leaves nothing it promised to delete", () =>
stravaAthleteId: "12345",
thresholdsJson: { sys: 130 },
onboardingCompletedAt: new Date(),
onboardingStep: 4,
passwordHash: "argon2-placeholder",
locale: "en",
unitPreference: "metric",