From 6adbee581e78e3cd120004a29c5a41b321817316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 18:19:34 +0200 Subject: [PATCH] Apply the review of the setup flow: the child's answers reach the child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Someone I look after" derived the child's answers onto the guardian's own record. The completion now takes the managed record the profile step just created, checks the same access rule the guardian's modules route applies, and lands the same derivation and dashboard seed on that record through the same record-keyed write; the guardian's own flow is stamped complete and latched without touching its map. Three integration cases pin it, and the child journey reads the child's map through a real switch — it asserted the guardian's map before, which pinned the wrong record. Also from the review: the checklist's visit row read a meta the visits list never publishes and now counts its body; the first-result tile read the measurements list as an array and now shows the reading; the demo allowlist admits the disclaimer and tour writes, with a structural test over every onboarding route file; restart clears the old first result, and the screen trusts the ledger before painting a result as done; the headroom sweep's raw-key check matches the flow's own key shapes; the demo migration script stops copying the dropped column; the questions-to-pass rule and the shell's onboarding-path rule are pure and pinned; the baseline form composes the shared action row; the schema diff loses the unrelated formatting churn. --- docs/api/openapi.yaml | 5 + e2e/setup-flow-bp.spec.ts | 7 +- e2e/setup-flow-child.spec.ts | 43 +++++- e2e/setup-flow-locale-overflow.spec.ts | 20 ++- e2e/setup-flow-visit.spec.ts | 19 ++- prisma/schema.prisma | 52 +++---- scripts/migrate-demo-edge01.sh | 4 +- .../proxy-demo-mode-allowlist.test.ts | 32 +++- src/app/api/onboarding/complete/route.ts | 117 ++++++++++++++- src/app/api/onboarding/restart/route.ts | 12 +- src/components/layout/auth-shell.tsx | 4 +- src/components/onboarding/baseline-form.tsx | 54 ++----- src/components/onboarding/confirm-screen.tsx | 31 ++-- .../onboarding/first-result-screen.tsx | 12 +- .../onboarding/getting-started-checklist.tsx | 25 ++-- .../onboarding/onboarding-shell.tsx | 2 +- .../onboarding/use-onboarding-flow.ts | 11 +- src/components/onboarding/welcome-screen.tsx | 2 +- .../onboarding/__tests__/checklist.test.ts | 25 ++++ .../onboarding/__tests__/wizard-steps.test.ts | 53 +++++++ src/lib/onboarding/checklist.ts | 13 ++ src/lib/onboarding/dashboard-seed.ts | 4 +- src/lib/onboarding/wizard-steps.ts | 33 +++++ src/lib/openapi/error-codes.ts | 2 + src/lib/validations/onboarding.ts | 10 ++ src/proxy.ts | 8 + tests/integration/onboarding-needs.test.ts | 140 ++++++++++++++++++ 27 files changed, 594 insertions(+), 146 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 93928d033..17e365281 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -27918,6 +27918,10 @@ components: OnboardingCompleteRequest: type: object properties: + managedRecordId: + type: string + minLength: 1 + maxLength: 64 displayName: type: string minLength: 1 @@ -29877,6 +29881,7 @@ components: nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, + onboarding.complete.notGuardian, onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.invalidBody, diff --git a/e2e/setup-flow-bp.spec.ts b/e2e/setup-flow-bp.spec.ts index ab3e9cdd9..afdf55b91 100644 --- a/e2e/setup-flow-bp.spec.ts +++ b/e2e/setup-flow-bp.spec.ts @@ -95,9 +95,10 @@ test.describe("setup flow — blood pressure and weight, typed in", () => { await page.locator("#dia").fill("78"); await page.locator("#puls").fill("66"); await page.getByRole("button", { name: /^save$/i }).click(); - await expect( - page.locator('[data-slot="onboarding-first-result-done"]'), - ).toBeVisible({ timeout: 15_000 }); + const done = page.locator('[data-slot="onboarding-first-result-done"]'); + await expect(done).toBeVisible({ timeout: 15_000 }); + // The reading on its tile, not only a tick: the value that was typed. + await expect(done).toContainText("124/78", { timeout: 15_000 }); await next(page); await expectScreen(page, "done"); diff --git a/e2e/setup-flow-child.spec.ts b/e2e/setup-flow-child.spec.ts index e573d0b4a..7f9efc52c 100644 --- a/e2e/setup-flow-child.spec.ts +++ b/e2e/setup-flow-child.spec.ts @@ -5,9 +5,18 @@ * and creates the profile on the confirm screen, after the questions — the * maintainer's answer to spec question 2, and the only order the routes * allow, since the three writes refuse under an acting-account switch. The - * immunization log is on for that answer, the first-result screen is not in - * the order (its tasks would write the wrong record), and the done screen - * offers the new record through the same switch the account menu uses. + * answers are then applied to the CHILD: the completion carries the new + * record's id, the derivation lands there (immunization log on, everything + * the answers did not name off), and the guardian's own module map is left + * exactly as it was. The first-result screen is not in the order (its tasks + * would write the guardian's record), and the done screen offers the new + * record through the same switch the account menu uses. + * + * This spec once asserted `glucose === false` on the GUARDIAN's map — the + * review's H1 — which pinned the child's answers re-ordering a parent's own + * record. It now reads the child's map through a real switch and the + * guardian's map for being untouched, so a regression to deriving onto the + * caller fails twice over. * * Creating the profile resolves `requireFreshMfa`, so the account carries a * confirmed factor (stamped by global setup after its login) and the fixture @@ -83,18 +92,42 @@ test.describe("setup flow — a child's managed profile", () => { page.locator('[data-slot="onboarding-open-managed-record"]'), ).toBeVisible({ timeout: 15_000 }); + // The guardian's own record: flow complete, module map untouched — a + // fresh account carries no explicit map, so nothing reads `false`. const me = await readMe(page); - expect(me.modules.vaccinations).not.toBe(false); - expect(me.modules.glucose).toBe(false); expect(me.onboarding.completedAt).not.toBeNull(); expect( me.onboarding.steps.find((step) => step.id === "first-result")?.status, ).toBe("skipped"); + for (const key of ["glucose", "sleep", "mood", "labs", "medications"]) { + expect(me.modules[key], `guardian module ${key}`).not.toBe(false); + } const managedEntries = (me.accountAccess?.accounts ?? []).filter( (entry) => entry.recordKind === "managed", ); expect(managedEntries).toHaveLength(1); + // The child's record, read through a real switch: the answers landed + // here. Switched back afterwards so the jar leaves the way it came. + const switched = await page.request.post("/api/account/switch", { + data: { accountId: managedEntries[0].accountId }, + }); + expect(switched.status(), "switching into the child's record").toBe(200); + try { + const child = await readMe(page); + expect(child.modules.vaccinations).not.toBe(false); + for (const key of ["glucose", "sleep", "mood", "labs", "medications"]) { + expect(child.modules[key], `child module ${key}`).toBe(false); + } + expect(child.onboarding.needs.recordTarget).toBe("someone-else"); + expect(child.onboarding.completedAt).not.toBeNull(); + } finally { + const back = await page.request.post("/api/account/switch", { + data: { accountId: null }, + }); + expect(back.status(), "switching back").toBe(200); + } + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/"); }); diff --git a/e2e/setup-flow-locale-overflow.spec.ts b/e2e/setup-flow-locale-overflow.spec.ts index 5ac8961a6..5dabc891d 100644 --- a/e2e/setup-flow-locale-overflow.spec.ts +++ b/e2e/setup-flow-locale-overflow.spec.ts @@ -52,8 +52,15 @@ const SCREENS: Array<{ screen: SetupScreen; path: string }> = [ { screen: "done", path: "/onboarding/done" }, ]; -/** i18n keys look like `section.subKey`; a rendered one is a lookup that fell through. */ -const RAW_KEY = /\b[a-z]+(?:[A-Z][a-z]+)?\.[a-z][A-Za-z0-9_.-]+\b/; +/** + * A rendered i18n key is a lookup that fell through. The flow's keys are + * three or more dotted segments (`onboarding.flow.who.options.me`, + * `onboarding.steps.first-result`), lowercase and kebab as often as camel — + * so the test is the dotted shape itself, not a camelCase segment inside it + * (which the locale-switch smoke test requires and which most of these keys + * do not have). No sentence on these screens carries such a run. + */ +const RAW_KEY = /\b[a-z]+(?:\.[a-zA-Z][\w-]*){2,}\b/; /** * Lengthen every text node under the shell by 30%, then measure. Padding @@ -172,11 +179,10 @@ test.describe("setup flow — every locale at 30% longer strings", () => { // No raw key before the padding touches the text. const body = await page.locator(SHELL).innerText(); const match = body.match(RAW_KEY); - if (match && /\.[a-z][A-Z]/.test(match[0])) { - throw new Error( - `raw i18n key on ${item.screen} in ${locale}: ${match[0]}`, - ); - } + expect( + match?.[0] ?? null, + `raw i18n key on ${item.screen} in ${locale}`, + ).toBeNull(); const result = await padAndMeasure(page); const label = `${item.screen} ${locale} @${vp.label}`; diff --git a/e2e/setup-flow-visit.spec.ts b/e2e/setup-flow-visit.spec.ts index 5f7bfd710..400bc777a 100644 --- a/e2e/setup-flow-visit.spec.ts +++ b/e2e/setup-flow-visit.spec.ts @@ -71,11 +71,26 @@ test.describe("setup flow — a visit within a month, with labs", () => { await page.locator('[data-slot="onboarding-open-dashboard"]').click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/"); - // The checklist opens with the visit row on it. + // The checklist opens with the visit row on it, not yet done. const checklist = page.locator('[data-testid="onboarding-card"]'); await expect(checklist).toBeVisible({ timeout: 15_000 }); await expect(checklist.locator("#getting-started-body")).toBeVisible(); - await expect(checklist.locator('a[href="/checkups"]')).toBeVisible(); + const visitRow = checklist.locator('li[data-item-id="visit"]'); + await expect(visitRow).toHaveAttribute("data-done", "false"); + await expect(visitRow.locator('a[href="/checkups"]')).toBeVisible(); + + // Book the visit; the row reads the visits list's own body and flips. + const booked = await page.request.post("/api/encounters", { + data: { + occurredAt: new Date(Date.now() + 7 * 86_400_000).toISOString(), + status: "PLANNED", + }, + }); + expect(booked.status(), "booking the visit").toBe(201); + await page.reload({ waitUntil: "domcontentloaded" }); + await expect( + page.locator('[data-testid="onboarding-card"] li[data-item-id="visit"]'), + ).toHaveAttribute("data-done", "true", { timeout: 15_000 }); const me = await readMe(page); expect(me.modules.labs).not.toBe(false); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c95508580..71bcfeb6d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -980,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`. @@ -4693,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") @@ -5320,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. @@ -5604,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. @@ -6120,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 @@ -6149,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) diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index e91c66d92..5c9521e0e 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -339,7 +339,7 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat glucose_unit, unit_preference, ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted, ai_openai_key_encrypted, ai_provider_chain, - onboarding_completed_at, onboarding_step, onboarding_tour_completed, + onboarding_completed_at, onboarding_tour_completed, healthkit_config_json, healthkit_last_synced_at, display_name, last_report_practice_name, coach_prefs_json, source_priority_json, last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs, @@ -379,7 +379,7 @@ readonly USERS_SELECT="id, username, email, password_hash, NULL::text AS ai_local_key_encrypted, NULL::text AS ai_openai_key_encrypted, ai_provider_chain, - onboarding_completed_at, onboarding_step, onboarding_tour_completed, + onboarding_completed_at, onboarding_tour_completed, healthkit_config_json, healthkit_last_synced_at, display_name, last_report_practice_name, coach_prefs_json, source_priority_json, last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs, diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index 9053e4587..d51c61082 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +import { globSync, readFileSync } from "node:fs"; +import { join, sep } from "node:path"; /** * Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy @@ -73,7 +75,35 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { expect(proxy(makeRequest("/api/onboarding/answers", "DELETE")).status).toBe( 403, ); - expect(proxy(makeRequest("/api/onboarding/tour", "POST")).status).toBe(403); + expect(proxy(makeRequest("/api/onboarding/tour", "DELETE")).status).toBe( + 403, + ); + }); + + it("admits every mutating onboarding route, read off the route files", () => { + // v1.39 (C2) — the demo has to be able to walk the whole flow, and the + // flow's welcome and exit make writes the C1 allowlist did not carry: the + // disclaimer acknowledgment (both welcome buttons await it) and the tour + // checkpoint. A hand-kept list of three paths could not see the other + // two, so this reads the route files instead: every exported mutating + // handler under `src/app/api/onboarding` must pass the proxy. + const onboardingApi = join(process.cwd(), "src/app/api/onboarding"); + const routes = globSync("**/route.ts", { cwd: onboardingApi }) + .map((p) => p.split(sep).join("/")) + .sort(); + expect(routes.length, "the onboarding route files").toBeGreaterThan(3); + const missing: string[] = []; + for (const rel of routes) { + const source = readFileSync(join(onboardingApi, rel), "utf8"); + const path = `/api/onboarding/${rel.replace(/\/route\.ts$/, "")}`; + for (const method of ["POST", "PATCH", "PUT", "DELETE"] as const) { + if (!new RegExp(`export const ${method}\\b`).test(source)) continue; + if (proxy(makeRequest(path, method)).status === 403) { + missing.push(`${method} ${path}`); + } + } + } + expect(missing, "onboarding writes the demo cannot make").toEqual([]); }); it("still blocks a health-data mutation (POST /api/measurements)", () => { diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 7af775dac..3b82b55c6 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -3,6 +3,7 @@ import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { + apiError, apiSuccess, apiValidationError, getClientIp, @@ -30,6 +31,7 @@ import { readOnboardingRecordState, toOnboardingStateDto, } from "@/lib/onboarding/needs-store"; +import { readManagedProfileForGuardian } from "@/lib/managed-profiles/lifecycle"; import { writeRecordModulePreferences } from "@/lib/record-settings/modules"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -57,6 +59,17 @@ import { onboardingCompleteSchema } from "@/lib/validations/onboarding"; * 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. + * + * `managedRecordId` (C2) — "someone I look after". The answers were given + * FOR the profile the confirm screen just created, so the derivation and the + * seed land on THAT record: the same access rule the guardian's own + * `PATCH /api/record-settings/modules` applies (an active guardian grant on a + * managed record), the same `writeRecordModulePreferences`, the same audit + * event. The caller's own record is stamped complete and its derivation + * marker set — latched, so a later confirm cannot derive the child's answers + * onto the guardian — and its module map is not touched. A guardian's own + * dashboard and modules are theirs; the child's answers say nothing about + * them. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -102,6 +115,15 @@ export const POST = apiHandler(async (request: NextRequest) => { data.displayName = result.data.displayName; } + // The managed arm is checked BEFORE anything is stamped: a refused record + // must leave the flow exactly where it was, so the person can fix the + // answer or the profile and confirm again. + const managedRecordId = result.data.managedRecordId ?? null; + if (managedRecordId !== null) { + const refusal = await refuseManagedRecord(user.id, managedRecordId); + if (refusal) return refusal; + } + await prisma.user.update({ where: { id: user.id }, data, @@ -115,11 +137,54 @@ export const POST = apiHandler(async (request: NextRequest) => { user.id, readHeldUnitPreferences(user), request, + managedRecordId, ); return apiSuccess({ completed: true, ...(onboarding ? { onboarding } : {}) }); }); +/** + * Whether `managedRecordId` may receive this caller's answers: the record + * must be a managed profile the caller actively guards, and the answer to Q1 + * must be "someone-else" — the one answer that says the questions were about + * another person. A `null` means proceed. + */ +async function refuseManagedRecord( + userId: string, + managedRecordId: string, +): Promise { + const guarded = await readManagedProfileForGuardian({ + profileId: managedRecordId, + guardianId: userId, + }); + if (!guarded) { + annotate({ + action: { name: "onboarding.needs.complete" }, + meta: { outcome: "not_guardian" }, + }); + return apiError("Not a guardian of that record", 403, { + errorCode: "onboarding.complete.notGuardian", + }); + } + const record = await prisma.onboardingRecord.findUnique({ + where: { userId }, + select: ONBOARDING_RECORD_SELECT, + }); + const state = readOnboardingRecordState(record); + if (state.needs.recordTarget !== "someone-else") { + annotate({ + action: { name: "onboarding.needs.complete" }, + meta: { outcome: "record_target_mismatch" }, + }); + return apiError( + "The answers were not given for somebody else's record", + 422, + { errorCode: "onboarding.complete.recordTargetMismatch" }, + ); + } + return null; +} + /** * The needs half: derive the module map once, mark the confirm step, stamp the * flow's own completion. @@ -132,6 +197,7 @@ async function completeNeedsFlow( userId: string, held: HeldUnitPreferences, request: NextRequest, + managedRecordId: string | null, ) { const record = await prisma.onboardingRecord.findUnique({ where: { userId }, @@ -169,10 +235,14 @@ async function completeNeedsFlow( return toOnboardingStateDto(record, held); } + const now = new Date(); let derived = false; let dashboardSeeded = false; let keptForData: string[] = []; if (record.modulesDerivedAt === null) { + // The record the answers are about: the managed profile for "someone I + // look after" (verified above), otherwise the caller's own. + const targetId = managedRecordId ?? userId; const defaults = deriveOnboardingModuleDefaults({ recordTarget: state.needs.recordTarget, areas: state.needs.areas, @@ -184,7 +254,7 @@ async function completeNeedsFlow( }); const row = await prisma.user.findUnique({ - where: { id: userId }, + where: { id: targetId }, select: { modulePreferencesJson: true }, }); // Only the modules the answers would switch OFF are worth asking about, @@ -196,7 +266,7 @@ async function completeNeedsFlow( ); const holdsData = await modulesHoldingRecordData( prisma, - userId, + targetId, wouldSwitchOff, ); const merged = mergeDerivedModulePreferences( @@ -212,7 +282,7 @@ async function completeNeedsFlow( // unanswered cycle from recorded sex, and an untick is not a request to // retract that. await writeRecordModulePreferences({ - recordId: userId, + recordId: targetId, modulePreferences: merged, ...(defaults.cycleTracking ? { cycleTrackingEnabled: true } : {}), }); @@ -228,9 +298,14 @@ async function completeNeedsFlow( ...(defaults.cycleTracking ? ["cycleTrackingEnabled"] : []), ]; await auditLog("user.modules.update", { - userId, + userId: targetId, ipAddress: getClientIp(request), - details: { changed, keptForData: [...holdsData], source: "onboarding" }, + details: { + changed, + keptForData: [...holdsData], + source: "onboarding", + ...(managedRecordId ? { guardianId: userId } : {}), + }, }); keptForData = [...holdsData]; @@ -249,16 +324,43 @@ async function completeNeedsFlow( if (seededLayout) { const seeded = await prisma.user.updateMany({ where: { - id: userId, + id: targetId, dashboardWidgetsJson: { equals: Prisma.AnyNull }, }, data: { dashboardWidgetsJson: toJson(seededLayout) }, }); dashboardSeeded = seeded.count === 1; } + + // The managed record's own setup row: the answers it was configured + // from, every step settled, both stamps set. Its account payload then + // reads a finished setup rather than nine pending steps, and a guardian + // who opens the questions from inside that record later is offered a + // re-run, not a first run. + if (managedRecordId) { + const settled = steps.map((step) => ({ + ...step, + status: "done" as const, + })); + await prisma.onboardingRecord.upsert({ + where: { userId: managedRecordId }, + create: { + userId: managedRecordId, + needsJson: toJson(state.needs), + stepsJson: toJson(settled), + completedAt: now, + modulesDerivedAt: now, + }, + update: { + needsJson: toJson(state.needs), + stepsJson: toJson(settled), + completedAt: now, + modulesDerivedAt: now, + }, + }); + } } - const now = new Date(); const confirmed = steps.map((step) => step.id === "confirm" && step.status === "pending" ? { ...step, status: "done" as const } @@ -283,6 +385,7 @@ async function completeNeedsFlow( outcome: derived ? "derived" : "already_derived", keptForData: keptForData.length, dashboard_seeded: dashboardSeeded, + ...(managedRecordId ? { target: "managed_record" } : {}), }, }); diff --git a/src/app/api/onboarding/restart/route.ts b/src/app/api/onboarding/restart/route.ts index a64a23914..1f6e44062 100644 --- a/src/app/api/onboarding/restart/route.ts +++ b/src/app/api/onboarding/restart/route.ts @@ -17,9 +17,13 @@ * - 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. + * + * What it DOES clear, since v1.39 (C2): the first result. The reading, the + * medication or the connection the task produced stays where it landed — the + * ledger entry is only the record of the OFFER. Kept, it painted the re-run's + * first-result screen as already done, "Next" then skipped the write, and the + * step stayed pending: the flow resumed there forever and the checklist never + * went away. A re-run offers a task again and records what it produces again. * * `requireAuth()` for the reason the answers route gives: the caller's own * record, refused outright under an acting-account switch. @@ -36,6 +40,7 @@ import { } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { annotate } from "@/lib/logging/context"; import { defaultOnboardingSteps, @@ -93,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => { stepsJson: toJson(steps), completedAt: null, modulesDerivedAt: null, + firstResultJson: Prisma.DbNull, }, select: ONBOARDING_RECORD_SELECT, }); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index a57947e4d..c49a352dc 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -28,6 +28,7 @@ import { OfflineBanner } from "./offline-banner"; import { SharedRecordBanner } from "./shared-record-banner"; import { SharedRecordUnavailable } from "./shared-record-unavailable"; import { ModulePageGate } from "@/components/layout/module-page-gate"; +import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps"; import { RecordScopeHydrationGate } from "./record-scope-hydration-gate"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; @@ -133,8 +134,7 @@ export function AuthShell({ // The whole setup flow, not only its front door: every screen lives under // `/onboarding/` (v1.39 C2), and an exact match here put the // questions inside the full app chrome, sidebar and all. - const isOnboardingPage = - pathname === "/onboarding" || pathname.startsWith("/onboarding/"); + const isOnboardingPage = isOnboardingPathname(pathname); const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; // v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 104f290e4..3f0cdc266 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -1,11 +1,8 @@ "use client"; import { useEffect, useState } from "react"; -import Link from "next/link"; import { toast } from "sonner"; -import { Loader2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { BaselineFields, type BaselineFieldErrors, @@ -29,6 +26,7 @@ import { describeBaselineSaveOutcome, putBaselineProfile, } from "@/components/onboarding/baseline-form-utils"; +import { StepActions } from "@/components/onboarding/step-actions"; /** * The profile half of the confirm screen: display name, height, date of @@ -55,7 +53,7 @@ import { * filled later from Settings. */ -export interface BaselineInitialValues { +interface BaselineInitialValues { heightCm: number | null; dateOfBirth: string | null; gender: string | null; @@ -225,47 +223,13 @@ export function BaselineForm({ disabled={saving} /> -
- {backHref ? ( - - ) : ( -
+ void advance({ saveProfile: false })} + onNext={() => void advance({ saveProfile: true })} + nextLabel={t("onboarding.flow.confirm.cta")} + pending={saving || confirming} + /> ); } diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index e8db60ad8..9a0b053ca 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -20,15 +20,12 @@ import { localeLabels } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; import { MODULE_REGISTRY } from "@/lib/modules/registry"; import { confirmedModules } from "@/lib/onboarding/confirm-summary"; -import { - ONBOARDING_SKIPPABLE_STEP_IDS, - type OnboardingStateDto, -} from "@/lib/onboarding/needs"; +import type { OnboardingStateDto } from "@/lib/onboarding/needs"; import { firstResultApplies, nextScreen, previousScreen, - questionScreens, + questionsToPassBeforeConfirm, } from "@/lib/onboarding/wizard-steps"; /** @@ -71,7 +68,13 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { const unitPreference = needs.units.unitPreference ?? user?.unitPreference ?? "metric"; - async function finish() { + /** + * Complete the flow. `managedRecordId` is the profile "someone I look + * after" just created: the route then applies the derivation to THAT + * record and stamps this one complete without deriving, so the guardian's + * own modules are never re-ordered around the child's answers. + */ + async function finish(managedRecordId?: string) { if (finishing) return; setFinishing(true); try { @@ -79,16 +82,12 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { // unit-bearing area — is passed on the ledger before the completion, // because the route derives only once EVERY question is answered or // passed, and a step nobody was asked cannot be answered. - const shown = new Set(questionScreens(state)); - for (const id of ONBOARDING_SKIPPABLE_STEP_IDS) { - if (id === "first-result") continue; - const pending = - state.steps.find((step) => step.id === id)?.status === "pending"; - if (!shown.has(id) && pending) { - await answer.mutateAsync({ step: id, status: "skipped" }); - } + for (const id of questionsToPassBeforeConfirm(state)) { + await answer.mutateAsync({ step: id, status: "skipped" }); } - const { onboarding } = await complete.mutateAsync(); + const { onboarding } = await complete.mutateAsync( + managedRecordId ? { managedRecordId } : undefined, + ); let written = onboarding ?? state; if (!firstResultApplies(written)) { // Nothing to offer, so the step is passed rather than left owed: @@ -197,7 +196,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) { void finish()} + onCreated={(profile) => void finish(profile.id)} />

{t("onboarding.flow.confirm.managedLater")}

diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index 9e4a22ed7..d77894ab6 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -71,8 +71,13 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { const offer = chooseFirstResultTask(state.needs); const back = previousScreen(state, "first-result"); + // Completed only when the LEDGER says so as well: a restart puts the step + // back to pending, and a stored result from the previous run must not paint + // this screen as done, or "Next" would go to done without ever writing the + // step and the flow would resume here forever. const [completed, setCompleted] = useState( - state.firstResult?.task === offer?.task && + state.steps.find((step) => step.id === "first-result")?.status === "done" && + state.firstResult?.task === offer?.task && state.firstResult?.completedAt !== null, ); @@ -425,9 +430,10 @@ function LatestReadingTile({ queryFn: async () => Promise.all( storedTypes.map((type) => - apiGet( + // The list route answers `{ measurements, meta }`, not a bare array. + apiGet<{ measurements: MeasurementRow[] }>( `/api/measurements?type=${encodeURIComponent(type)}&limit=1`, - ).then((rows) => rows[0] ?? null), + ).then((page) => page.measurements[0] ?? null), ), ), }); diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index 60b306296..a1fa071ad 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -26,10 +26,11 @@ import { buildChecklist, checklistProgress, shouldShowChecklist, + upcomingVisitCountFrom, visibleChecklist, type ChecklistItemId, } from "@/lib/onboarding/checklist"; -import { apiFetchEnvelope, apiGet } from "@/lib/api/api-fetch"; +import { apiGet } from "@/lib/api/api-fetch"; import { CHECKLIST_DISMISSED_ALL_KEY as DISMISSED_ALL_KEY, CHECKLIST_DISMISSED_ITEMS_KEY as DISMISSED_ITEMS_KEY, @@ -89,10 +90,6 @@ const ITEM_LABEL_KEYS: Record< }, }; -interface EncounterListMeta { - upcoming?: number; -} - interface IntegrationsStatus { integrations?: Array<{ connected?: boolean; enabled?: boolean }>; } @@ -306,14 +303,12 @@ export function GettingStartedChecklist() { // for it: a visit within a month. Reads the visits list's own meta count so // the row flips done the moment one is on the calendar. const visitAsked = user?.onboarding?.needs.visit === "within-a-month"; - const { data: encountersMeta } = useQuery({ + const { data: upcomingVisitCount } = useQuery({ queryKey: queryKeys.onboardingUpcomingVisits(), - queryFn: async () => { - const { meta } = await apiFetchEnvelope( - "/api/encounters", - ); - return meta ?? {}; - }, + queryFn: async () => + upcomingVisitCountFrom( + await apiGet<{ upcoming: unknown[] }>("/api/encounters"), + ), enabled: checklistRelevant && visitAsked, }); @@ -346,7 +341,7 @@ export function GettingStartedChecklist() { notificationsConfigured, insightsConfigured, dismissedIds, - upcomingVisitCount: encountersMeta?.upcoming ?? 0, + upcomingVisitCount: upcomingVisitCount ?? 0, // v1.39 (C1) — the setup answers order these rows: medication first // for somebody who said they take one daily, the data-source row first // for somebody who named a wearable. The payload resolves them for the @@ -366,7 +361,7 @@ export function GettingStartedChecklist() { notificationsConfigured, insightsConfigured, dismissedIds, - encountersMeta?.upcoming, + upcomingVisitCount, ], ); @@ -509,6 +504,8 @@ export function GettingStartedChecklist() { return (
  • - apiPost("/api/onboarding/complete", {}), + mutationFn: async (input?: { managedRecordId: string }) => + apiPost("/api/onboarding/complete", input ?? {}), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); }, diff --git a/src/components/onboarding/welcome-screen.tsx b/src/components/onboarding/welcome-screen.tsx index 9eab4594e..736f4b69d 100644 --- a/src/components/onboarding/welcome-screen.tsx +++ b/src/components/onboarding/welcome-screen.tsx @@ -86,7 +86,7 @@ export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) { if (pending || !acknowledged) return; try { await acknowledge.mutateAsync(); - await complete.mutateAsync(); + await complete.mutateAsync(undefined); markChecklistExpanded(); router.push("/"); } catch (err) { diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index 66ff522f0..42f7edaaf 100644 --- a/src/lib/onboarding/__tests__/checklist.test.ts +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; + import { buildChecklist, checklistOrderFromNeeds, @@ -9,6 +11,7 @@ import { isProfileComplete, shouldShowChecklist, trendHintFor, + upcomingVisitCountFrom, visibleChecklist, type ChecklistItemId, } from "../checklist"; @@ -143,6 +146,28 @@ describe("buildChecklist", () => { }); }); +describe("upcomingVisitCountFrom (the visits list's wire shape)", () => { + it("counts the upcoming array of the body `GET /api/encounters` publishes", () => { + // The route answers `{ upcoming, past }` as data and publishes no meta; + // the row read `meta.upcoming` once and could never flip to done. + const body: EncounterListDTO = { upcoming: [], past: [] }; + expect(upcomingVisitCountFrom(body)).toBe(0); + const two: EncounterListDTO = { + upcoming: [{ id: "v1" }, { id: "v2" }] as unknown as EncounterDTO[], + past: [], + }; + expect(upcomingVisitCountFrom(two)).toBe(2); + }); + + it("reads a missing or malformed body as no visit", () => { + expect(upcomingVisitCountFrom(null)).toBe(0); + expect(upcomingVisitCountFrom(undefined)).toBe(0); + expect( + upcomingVisitCountFrom({ upcoming: 3 as unknown as unknown[] }), + ).toBe(0); + }); +}); + describe("the visit row (v1.39 C2)", () => { it("exists only for an answer that named a visit within a month", () => { const asked = buildChecklist( diff --git a/src/lib/onboarding/__tests__/wizard-steps.test.ts b/src/lib/onboarding/__tests__/wizard-steps.test.ts index 532b15abf..bbb82e687 100644 --- a/src/lib/onboarding/__tests__/wizard-steps.test.ts +++ b/src/lib/onboarding/__tests__/wizard-steps.test.ts @@ -19,6 +19,8 @@ import { import { BROWSER_CONNECTABLE_SOURCES, canVisitScreen, + isOnboardingPathname, + questionsToPassBeforeConfirm, chooseFirstResultTask, firstResultApplies, nextScreen, @@ -375,6 +377,57 @@ describe("canVisitScreen", () => { }); }); +describe("questionsToPassBeforeConfirm", () => { + it("passes the units question the flow never showed, and nothing shown", () => { + // No unit-bearing area: Q6 is hidden, still pending, and the completion + // route would otherwise answer "incomplete" — the first real run's find. + expect( + questionsToPassBeforeConfirm( + state(QUESTIONS_DONE, { recordTarget: "me", areas: ["mood"] }), + ), + ).toEqual(["units"]); + // Units shown and answered: nothing to pass. + expect( + questionsToPassBeforeConfirm( + state( + { ...QUESTIONS_DONE, units: "done" }, + { + recordTarget: "me", + areas: ["glucose"], + units: { glucoseUnit: "mg/dL", unitPreference: null }, + }, + ), + ), + ).toEqual([]); + }); + + it("never passes a question the person still owes on screen", () => { + expect( + questionsToPassBeforeConfirm( + state({ who: "done" }, { recordTarget: "me", areas: ["mood"] }), + ), + ).toEqual(["units"]); + }); + + it("leaves the first-result step to the confirm screen's own rule", () => { + expect( + questionsToPassBeforeConfirm( + state(QUESTIONS_DONE, { recordTarget: "me" }), + ), + ).not.toContain("first-result"); + }); +}); + +describe("isOnboardingPathname", () => { + it("covers the front door and every step, and nothing that only starts alike", () => { + expect(isOnboardingPathname("/onboarding")).toBe(true); + expect(isOnboardingPathname("/onboarding/who")).toBe(true); + expect(isOnboardingPathname("/onboarding/first-result")).toBe(true); + expect(isOnboardingPathname("/onboarding-export")).toBe(false); + expect(isOnboardingPathname("/")).toBe(false); + }); +}); + describe("stepCounter", () => { it("counts the questions only, at the size this flow actually has", () => { expect(stepCounter(state(), "who")).toEqual({ current: 1, total: 5 }); diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index 50264f49d..074e5d052 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -275,6 +275,19 @@ export function shouldShowChecklist(args: { return visible.some((item) => !item.done); } +/** + * The upcoming-visit count off the visits list's own body. `GET + * /api/encounters` answers `{ upcoming, past }` as DATA — there is no `meta` + * on that wire, and a reader that looked for one counted zero forever (the + * "prepare the visit" row could never flip). Pure, so the shape is pinned + * against the DTO the route publishes. + */ +export function upcomingVisitCountFrom( + body: { upcoming: readonly unknown[] } | null | undefined, +): number { + return Array.isArray(body?.upcoming) ? body.upcoming.length : 0; +} + /** * Profile is "complete" once height, date of birth and gender are all * set. Display name is captured automatically at signup, so it doesn't diff --git a/src/lib/onboarding/dashboard-seed.ts b/src/lib/onboarding/dashboard-seed.ts index 78c85070e..9ae090e27 100644 --- a/src/lib/onboarding/dashboard-seed.ts +++ b/src/lib/onboarding/dashboard-seed.ts @@ -47,9 +47,7 @@ export const AREA_WIDGET_SEED_MAP: Readonly< }); /** Q3 "yes" or "sometimes" promotes the medication tile. */ -export const MEDICATION_WIDGET_SEED: readonly DashboardWidgetId[] = [ - "medications", -]; +const MEDICATION_WIDGET_SEED: readonly DashboardWidgetId[] = ["medications"]; /** * The tiles the answers promote, in the default layout's order. Empty when diff --git a/src/lib/onboarding/wizard-steps.ts b/src/lib/onboarding/wizard-steps.ts index ee4ae3f09..71c5912a7 100644 --- a/src/lib/onboarding/wizard-steps.ts +++ b/src/lib/onboarding/wizard-steps.ts @@ -14,6 +14,7 @@ import type { OnboardingSourceKey } from "./needs"; import { hasEnteredOnboardingFlow, + ONBOARDING_SKIPPABLE_STEP_IDS, type OnboardingFirstResultTask, type OnboardingNeeds, type OnboardingStateDto, @@ -214,6 +215,38 @@ export function canVisitScreen( return index <= Math.max(order.indexOf(resumeScreen(state)), 1); } +/** + * The questions the confirm screen must pass on the ledger before it + * completes: every skippable question this flow never showed and that is + * still pending. The completion route derives only once EVERY question is + * answered or passed, and Q6 is a question the machine hides for a flow with + * no unit-bearing area — so without this the route answers "incomplete" to + * three flows out of four, which is exactly what the first real run found. + * The first-result step is not a question and is passed by the confirm + * screen on its own rule. + */ +export function questionsToPassBeforeConfirm( + state: FlowState, +): Exclude<(typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number], "first-result">[] { + const shown = new Set(questionScreens(state)); + return ONBOARDING_SKIPPABLE_STEP_IDS.filter( + (id): id is Exclude => + id !== "first-result" && + !shown.has(id) && + status(state, id) === "pending", + ); +} + +/** + * Whether a pathname is the setup flow's. The auth shell paints these screens + * without the app chrome, and it once matched only the front door — every + * question screen then rendered with the sidebar. Pure, so the shell's + * decision is pinned without a browser. + */ +export function isOnboardingPathname(pathname: string): boolean { + return pathname === "/onboarding" || pathname.startsWith("/onboarding/"); +} + /** * "2 of 6" — for the question screens only. The welcome, confirm, first * result and done screens carry no counter: the count is a promise about how diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 7a9b7d909..3cbeda8c8 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -237,6 +237,8 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "onboarding.answers.invalid", "onboarding.answers.rateLimited", "onboarding.complete.invalid", + "onboarding.complete.notGuardian", + "onboarding.complete.recordTargetMismatch", "onboarding.disclaimer.invalid", "onboarding.restart.invalid", "onboarding.restart.rateLimited", diff --git a/src/lib/validations/onboarding.ts b/src/lib/validations/onboarding.ts index 1e3f5511a..4fee80809 100644 --- a/src/lib/validations/onboarding.ts +++ b/src/lib/validations/onboarding.ts @@ -23,6 +23,16 @@ import { z } from "zod/v4"; * empty body and writes its profile through `PUT /api/auth/profile`. */ export const onboardingCompleteSchema = z.object({ + /** + * v1.39 (C2) — the managed record the answers were given FOR. Sent by the + * confirm screen after "someone I look after" created the profile: the + * module map and the dashboard order derived from the caller's answers are + * applied to that record, and the caller's own record is stamped complete + * without deriving. The caller must be a guardian of it, and the answer to + * Q1 must be "someone-else"; anything else is refused rather than applied + * to the wrong record. + */ + managedRecordId: z.string().trim().min(1).max(64).optional(), displayName: z.string().trim().min(1).max(50).optional(), heightCm: z.number().min(50).max(300).optional(), dateOfBirth: z.string().optional(), diff --git a/src/proxy.ts b/src/proxy.ts index 97a86c93a..5184878e7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -166,6 +166,14 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = { path: "/api/onboarding/answers", method: "PATCH" }, { path: "/api/onboarding/complete", method: "POST" }, { path: "/api/onboarding/restart", method: "POST" }, + // v1.39 (C2) — the two writes the flow's welcome and exit make: the + // one-time disclaimer acknowledgment gates both welcome buttons, and the + // tour's progress checkpoint is what the done screen's offer leads to. + // Without the first, a demo account on a fresh seed (or after a version + // bump of the disclaimer) got a 403 from "Set up" and "Skip for now" both. + // Neither creates health data. + { path: "/api/onboarding/disclaimer", method: "POST" }, + { path: "/api/onboarding/tour", method: "POST" }, ]; // Legacy route redirects (German → English) diff --git a/tests/integration/onboarding-needs.test.ts b/tests/integration/onboarding-needs.test.ts index cf7fcd38e..b46528eb9 100644 --- a/tests/integration/onboarding-needs.test.ts +++ b/tests/integration/onboarding-needs.test.ts @@ -538,7 +538,147 @@ describe("POST /api/onboarding/complete", () => { }); }); +describe("POST /api/onboarding/complete for somebody else's record", () => { + async function guardianWithChild() { + const guardian = await makeUser("guardian"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Child record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + await signIn(guardian.id); + await patchAnswer({ step: "who", recordTarget: "someone-else" }); + await patchAnswer({ step: "areas", areas: ["blood-pressure"] }); + await patchAnswer({ step: "medication", medication: "no" }); + await patchAnswer({ step: "sources", sources: ["manual"] }); + await patchAnswer({ step: "visit", visit: "no" }); + await patchAnswer({ step: "units", status: "skipped" }); + return { guardian, profile }; + } + + it("derives onto the managed record and leaves the guardian's own map alone", async () => { + // The review's H1: a parent setting up a child's record must not have + // their own modules switched off around the child's answers. The + // derivation is the same function, applied through the same record-keyed + // write the guardian's modules route uses — to the child. + const { guardian, profile } = await guardianWithChild(); + + const res = await postComplete({ managedRecordId: profile.id }); + expect(res.status).toBe(200); + + const child = await modulePrefs(profile.id); + expect(child.vaccinations).toBe(true); + expect(child.insights).toBe(true); + expect(child.glucose).toBe(false); + expect(child.medications).toBe(false); + expect(child.doctorReport).toBe(false); + + // Untouched: a fresh account carries no map at all, and still does. + expect(await modulePrefs(guardian.id)).toEqual({}); + + // The guardian's own flow is complete and its derivation latched, so a + // later confirm cannot derive the child's answers onto the guardian. + const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: guardian.id }, + }); + expect(own.completedAt).not.toBeNull(); + expect(own.modulesDerivedAt).not.toBeNull(); + expect(statusOf(await readState(res), "confirm")).toBe("done"); + + // The child's own setup row reads as finished, from the same answers. + const theirs = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: profile.id }, + }); + expect(theirs.completedAt).not.toBeNull(); + expect(theirs.modulesDerivedAt).not.toBeNull(); + const dashboard = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: profile.id }, + select: { dashboardWidgetsJson: true }, + }); + expect(dashboard.dashboardWidgetsJson).not.toBeNull(); + + // A second confirm derives nowhere: the guardian is latched, and the + // child keeps what it has. + expect((await postComplete({ managedRecordId: profile.id })).status).toBe( + 200, + ); + expect(await modulePrefs(guardian.id)).toEqual({}); + }); + + it("refuses a record the caller does not guard, and touches nothing", async () => { + const { guardian } = await guardianWithChild(); + const stranger = await makeUser("stranger-guardian"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile: theirs } = await createManagedProfile({ + creatorId: stranger.id, + displayName: "Somebody else's child", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + + const res = await postComplete({ managedRecordId: theirs.id }); + expect(res.status).toBe(403); + expect(await modulePrefs(theirs.id)).toEqual({}); + expect(await modulePrefs(guardian.id)).toEqual({}); + const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: guardian.id }, + }); + expect(own.completedAt).toBeNull(); + // Nothing stamped the first-run gate either. + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: guardian.id }, + select: { onboardingCompletedAt: true }, + }); + expect(row.onboardingCompletedAt).toBeNull(); + }); + + it("refuses a managed record when the answers were given for the caller", async () => { + const guardian = await makeUser("guardian-me"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Child record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + await signIn(guardian.id); + await answerTheQuestions(); // Q1 = "me" + + const res = await postComplete({ managedRecordId: profile.id }); + expect(res.status).toBe(422); + expect(await modulePrefs(profile.id)).toEqual({}); + expect(await modulePrefs(guardian.id)).toEqual({}); + }); +}); + describe("POST /api/onboarding/restart", () => { + it("clears the first result so a re-run offers the task again", async () => { + // M2: kept, the old result painted the re-run's first-result screen as + // done, "Next" skipped the write, and the step stayed pending forever. + const user = await makeUser("restart-first-result"); + await signIn(user.id); + await answerTheQuestions(); + expect((await postComplete()).status).toBe(200); + await patchAnswer({ + step: "first-result", + firstResult: { task: "add-medication", target: null, completed: true }, + }); + expect((await readMe()).firstResult?.completedAt).not.toBeNull(); + + expect((await postRestart()).status).toBe(200); + const after = await readMe(); + expect(after.firstResult).toBeNull(); + expect(statusOf(after, "first-result")).toBe("pending"); + }); + it("resets the steps and leaves the modules where they are", async () => { const user = await makeUser("restart"); await signIn(user.id);