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