From a2e407909c73766554e3a61f62ada1e8feaf59eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 18:58:21 +0200 Subject: [PATCH] Never derive answers given for somebody else onto the guardian "Finish without the profile" completed the flow with no record id, and the route then derived the child's answers onto the guardian's own record. The server now decides: answers given for somebody else with no record to apply them to complete and latch the flow without deriving or seeding anything, whatever the client sent. The checklist carries the profile instead and reads none of those answers as the guardian's own. A record id the caller does not guard answers 404 like every id-addressed managed-profile route, one answer for missing, not managed and not theirs alike; the contract documents the field, the 404 and the 422 text. Pinned by two integration cases and a second child journey. --- docs/api/openapi.yaml | 43 +++++++++++++--- e2e/setup-flow-child.spec.ts | 51 +++++++++++++++++++ messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- src/app/api/onboarding/complete/route.ts | 35 ++++++++++--- .../onboarding/getting-started-checklist.tsx | 23 +++++++-- .../onboarding/__tests__/checklist.test.ts | 42 +++++++++++++++ src/lib/onboarding/checklist.ts | 40 ++++++++++++++- src/lib/openapi/error-codes.ts | 2 +- src/lib/openapi/routes/onboarding.ts | 11 ++-- src/lib/validations/onboarding.ts | 10 +++- tests/integration/onboarding-needs.test.ts | 44 +++++++++++++++- 17 files changed, 304 insertions(+), 32 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 241eb8b55..88ec22465 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -16410,6 +16410,16 @@ paths: Cookie or wildcard Bearer; `userId` is never read from the body. + + + `managedRecordId` — "someone I look after". The answers were given for the profile the confirm screen created, + so the derivation and the seed land on THAT record through the same record-keyed write the guardian's modules + route uses, its setup row is written as finished, and the caller's own record is stamped complete and latched + with its map untouched. A managed record the caller does not actively guard, a record that is not a managed + profile, or an id that does not exist all answer 404 with nothing stamped; a body naming a record when Q1 was + not `someone-else` is a 422. With Q1 = `someone-else` and NO record id, the flow completes and latches without + deriving anything onto anybody: there is no record the answers describe yet, and they never describe the + guardian's. requestBody: required: true content: @@ -16426,6 +16436,14 @@ paths: schema: $ref: "#/components/schemas/OnboardingCompleteEnvelope" "401": *a1 + "404": + description: "`managedRecordId` names no managed profile the caller actively guards — missing, not a managed record, or + not theirs; one answer for all three. `meta.errorCode` = `onboarding.complete.recordNotFound`. Nothing was + stamped." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "413": description: Body exceeds 64 KiB. content: @@ -16439,8 +16457,10 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": - description: The body did not validate. `meta.errorCode` = `onboarding.complete.invalid`. Single-message, not the - multi-issue envelope. + description: The body did not validate (`meta.errorCode` = `onboarding.complete.invalid`; single-message, not the + multi-issue envelope), or `managedRecordId` was sent for answers that were not given for somebody else's + record — Q1 was `me` or `both` (`meta.errorCode` = `onboarding.complete.recordTargetMismatch`; nothing + stamped). content: application/json: schema: @@ -27983,6 +28003,13 @@ components: type: object properties: managedRecordId: + description: The managed record the setup answers were given FOR — the profile "someone I look after" created on the + confirm screen. When present, the module map and the dashboard order derived from the caller's answers are + applied to THAT record and its own setup row is written as finished; the caller's own record is stamped + complete and its derivation latched without any module being touched. Honoured only for a managed profile + the caller actively guards (otherwise 404 `onboarding.complete.recordNotFound`) and only when the answer to + Q1 was `someone-else` (otherwise 422 `onboarding.complete.recordTargetMismatch`); nothing is stamped on a + refusal. Absent with Q1 = `someone-else`, the flow completes without deriving anything anywhere. type: string minLength: 1 maxLength: 64 @@ -28002,10 +28029,12 @@ components: - MALE - FEMALE - OTHER - description: "Optional profile fields to save alongside the completion stamp. Every field is optional and only a truthy - value is written — sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing - it. There is no way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new - Date(...)`: an unparseable value is silently ignored, not refused." + description: "The completion stamp's body. `managedRecordId` (v1.39) names the managed record the answers were given for + — see the field. The profile fields are the legacy half: every one optional, only a truthy value is written — + sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing it, and there is no + way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new Date(...)`: an + unparseable value is silently ignored, not refused. The web flow writes its profile through `PUT + /api/auth/profile` instead." DisclaimerAckRequest: type: object properties: @@ -29945,7 +29974,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.complete.recordNotFound, 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-child.spec.ts b/e2e/setup-flow-child.spec.ts index 7f9efc52c..b82d23904 100644 --- a/e2e/setup-flow-child.spec.ts +++ b/e2e/setup-flow-child.spec.ts @@ -131,4 +131,55 @@ test.describe("setup flow — a child's managed profile", () => { await page.locator('[data-slot="onboarding-open-dashboard"]').click(); await expect.poll(() => new URL(page.url()).pathname).toBe("/"); }); + + test("finishing without the profile derives nothing onto the guardian", async ({ + page, + }) => { + // The other exit of the same screen (H1'): no profile is created, so + // there is no record the answers describe. The guardian's own map must + // stay exactly as it was, and the checklist carries the profile instead. + test.setTimeout(90_000); + + await page.goto("/onboarding"); + await acceptAndSetUp(page); + await choose(page, "who", ["someone-else"]); + await next(page); + await expectScreen(page, "areas"); + await choose(page, "areas", ["blood-pressure"]); + await next(page); + await expectScreen(page, "medication"); + await choose(page, "medication", ["yes"]); + await next(page); + await expectScreen(page, "sources"); + await choose(page, "sources", ["oura"]); + await next(page); + await expectScreen(page, "visit"); + await choose(page, "visit", ["within-a-month"]); + await next(page); + + await expectScreen(page, "confirm"); + await page + .locator('[data-slot="onboarding-finish-without-profile"]') + .click(); + await expectScreen(page, "done"); + await expect( + page.locator('[data-slot="onboarding-open-managed-record"]'), + ).toHaveCount(0); + + const me = await readMe(page); + expect(me.onboarding.completedAt).not.toBeNull(); + for (const key of ["glucose", "sleep", "mood", "labs", "vaccinations"]) { + expect(me.modules[key], `guardian module ${key}`).not.toBe(false); + } + expect((me.accountAccess?.accounts ?? []).length).toBe(0); + + await page.locator('[data-slot="onboarding-open-dashboard"]').click(); + await expect.poll(() => new URL(page.url()).pathname).toBe("/"); + const checklist = page.locator('[data-testid="onboarding-card"]'); + await expect(checklist).toBeVisible({ timeout: 15_000 }); + await expect( + checklist.locator('li[data-item-id="managedProfile"]'), + ).toHaveAttribute("data-done", "false"); + await expect(checklist.locator('li[data-item-id="visit"]')).toHaveCount(0); + }); }); diff --git a/messages/de.json b/messages/de.json index 67859327b..e301cb14a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4739,7 +4739,10 @@ "visitTitle": "Auf den Arztbesuch vorbereiten", "visitDescription": "Trag den Termin in den Kalender ein; der Arztbericht bündelt, was du mitbringst.", "visitCta": "Besuche öffnen", - "dataSourceNamedTitle": "{source} verbinden" + "dataSourceNamedTitle": "{source} verbinden", + "managedProfileTitle": "Profil der betreuten Person anlegen", + "managedProfileDescription": "Deine Antworten galten der Akte einer anderen Person; sie wird unter Geteilter Zugriff angelegt.", + "managedProfileCta": "Profil anlegen" } }, "trendHints": { diff --git a/messages/en.json b/messages/en.json index 8f3dee7cc..c077bd314 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4739,7 +4739,10 @@ "visitTitle": "Prepare for the visit", "visitDescription": "Put the appointment on the calendar; the doctor report bundles what to bring.", "visitCta": "Open visits", - "dataSourceNamedTitle": "Connect {source}" + "dataSourceNamedTitle": "Connect {source}", + "managedProfileTitle": "Create the profile you look after", + "managedProfileDescription": "Your answers were given for another person's record; it is created under Shared access.", + "managedProfileCta": "Create the profile" } }, "trendHints": { diff --git a/messages/es.json b/messages/es.json index 257ef0064..bdeb99f13 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4739,7 +4739,10 @@ "visitTitle": "Prepararse para la visita", "visitDescription": "Anota la cita en el calendario; el informe médico reúne lo que debes llevar.", "visitCta": "Abrir visitas", - "dataSourceNamedTitle": "Conectar {source}" + "dataSourceNamedTitle": "Conectar {source}", + "managedProfileTitle": "Crear el perfil de la persona a tu cargo", + "managedProfileDescription": "Tus respuestas eran para el registro de otra persona; se crea en Acceso compartido.", + "managedProfileCta": "Crear el perfil" } }, "trendHints": { diff --git a/messages/fr.json b/messages/fr.json index f05bd52b5..87ab6bc25 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4739,7 +4739,10 @@ "visitTitle": "Préparer la visite", "visitDescription": "Notez le rendez-vous dans le calendrier ; le rapport médical rassemble ce qu'il faut apporter.", "visitCta": "Ouvrir les visites", - "dataSourceNamedTitle": "Connecter {source}" + "dataSourceNamedTitle": "Connecter {source}", + "managedProfileTitle": "Créer le profil de la personne dont vous vous occupez", + "managedProfileDescription": "Vos réponses concernaient le dossier d'une autre personne ; il se crée dans Accès partagé.", + "managedProfileCta": "Créer le profil" } }, "trendHints": { diff --git a/messages/it.json b/messages/it.json index f98863e1d..b214911a3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4739,7 +4739,10 @@ "visitTitle": "Prepara la visita", "visitDescription": "Segna l'appuntamento nel calendario; il referto per il medico raccoglie cosa portare.", "visitCta": "Apri le visite", - "dataSourceNamedTitle": "Collega {source}" + "dataSourceNamedTitle": "Collega {source}", + "managedProfileTitle": "Crea il profilo della persona di cui ti prendi cura", + "managedProfileDescription": "Le tue risposte riguardavano la cartella di un'altra persona; si crea in Accesso condiviso.", + "managedProfileCta": "Crea il profilo" } }, "trendHints": { diff --git a/messages/ko.json b/messages/ko.json index ef9268f8d..aa7516eab 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4739,7 +4739,10 @@ "visitTitle": "진료 준비하기", "visitDescription": "예약을 달력에 넣으세요. 진료 보고서가 가져갈 내용을 모아 줍니다.", "visitCta": "진료 열기", - "dataSourceNamedTitle": "{source} 연결" + "dataSourceNamedTitle": "{source} 연결", + "managedProfileTitle": "돌보는 사람의 프로필 만들기", + "managedProfileDescription": "답변은 다른 사람의 기록에 대한 것이었습니다. 프로필은 공유 접근에서 만듭니다.", + "managedProfileCta": "프로필 만들기" } }, "trendHints": { diff --git a/messages/pl.json b/messages/pl.json index 32776344a..c495d15aa 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4739,7 +4739,10 @@ "visitTitle": "Przygotuj się do wizyty", "visitDescription": "Wpisz termin do kalendarza; raport dla lekarza zbiera to, co warto zabrać.", "visitCta": "Otwórz wizyty", - "dataSourceNamedTitle": "Połącz {source}" + "dataSourceNamedTitle": "Połącz {source}", + "managedProfileTitle": "Utwórz profil osoby, którą się opiekujesz", + "managedProfileDescription": "Twoje odpowiedzi dotyczyły kartoteki innej osoby; tworzy się ją w Dostępie współdzielonym.", + "managedProfileCta": "Utwórz profil" } }, "trendHints": { diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 3b82b55c6..ebb1b82d0 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -158,12 +158,15 @@ async function refuseManagedRecord( guardianId: userId, }); if (!guarded) { + // 404 for every case at once — missing, not a managed profile, not + // guarded by the caller — as the id-addressed managed-profile route + // answers (`managed_profile.not_found`): one answer, no existence oracle. annotate({ action: { name: "onboarding.needs.complete" }, - meta: { outcome: "not_guardian" }, + meta: { outcome: "record_not_found" }, }); - return apiError("Not a guardian of that record", 403, { - errorCode: "onboarding.complete.notGuardian", + return apiError("Managed record not found", 404, { + errorCode: "onboarding.complete.recordNotFound", }); } const record = await prisma.onboardingRecord.findUnique({ @@ -239,7 +242,16 @@ async function completeNeedsFlow( let derived = false; let dashboardSeeded = false; let keptForData: string[] = []; - if (record.modulesDerivedAt === null) { + // "Someone I look after" with no profile to apply the answers to: the + // person declined (or could not, without a second factor) to create it on + // the confirm screen. There is no record the answers describe yet, and + // they must not describe the guardian's — so the flow is stamped complete + // and LATCHED without deriving or seeding anything. The profile created + // later under Settings starts from the defaults. Enforced here, on the + // server, whatever the client sent. + const answersForNobody = + state.needs.recordTarget === "someone-else" && managedRecordId === null; + if (record.modulesDerivedAt === null && !answersForNobody) { // 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; @@ -338,6 +350,11 @@ async function completeNeedsFlow( // who opens the questions from inside that record later is offered a // re-run, not a first run. if (managedRecordId) { + // `needsJson` is the guardian's answers as given, `recordTarget` + // "someone-else" included: from inside the child's record that reads + // as "for somebody else", which is true of how it was set up. Nothing + // acts on it there — the three writes refuse under a switch — so it is + // kept as the honest record of the flow that configured the row. const settled = steps.map((step) => ({ ...step, status: "done" as const, @@ -374,7 +391,9 @@ async function completeNeedsFlow( // or a replay of the confirm quietly moves the instant the setup // finished at. completedAt: record.completedAt ?? now, - ...(derived ? { modulesDerivedAt: now } : {}), + // Latched on a derivation, and on the no-record arm as well: a later + // confirm must not apply the child's answers to the guardian either. + ...(derived || answersForNobody ? { modulesDerivedAt: now } : {}), }, select: ONBOARDING_RECORD_SELECT, }); @@ -382,7 +401,11 @@ async function completeNeedsFlow( annotate({ action: { name: "onboarding.needs.complete" }, meta: { - outcome: derived ? "derived" : "already_derived", + outcome: answersForNobody + ? "no_record_for_answers" + : derived + ? "derived" + : "already_derived", keptForData: keptForData.length, dashboard_seeded: dashboardSeeded, ...(managedRecordId ? { target: "managed_record" } : {}), diff --git a/src/components/onboarding/getting-started-checklist.tsx b/src/components/onboarding/getting-started-checklist.tsx index a1fa071ad..09d678d29 100644 --- a/src/components/onboarding/getting-started-checklist.tsx +++ b/src/components/onboarding/getting-started-checklist.tsx @@ -12,6 +12,7 @@ import { Sparkles, Stethoscope, User2, + UserPlus, Wifi, X, } from "lucide-react"; @@ -47,6 +48,7 @@ const ITEM_ICONS: Record = { notifications: Bell, insights: Sparkles, visit: Stethoscope, + managedProfile: UserPlus, }; const ITEM_LABEL_KEYS: Record< @@ -88,6 +90,11 @@ const ITEM_LABEL_KEYS: Record< description: "gettingStarted.items.visitDescription", cta: "gettingStarted.items.visitCta", }, + managedProfile: { + title: "gettingStarted.items.managedProfileTitle", + description: "gettingStarted.items.managedProfileDescription", + cta: "gettingStarted.items.managedProfileCta", + }, }; interface IntegrationsStatus { @@ -302,7 +309,11 @@ export function GettingStartedChecklist() { // v1.39 (C2) — the "prepare the visit" row, only for the answer that asked // 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"; + // Answers given for somebody else's record (Q1 "someone I look after") are + // the child's, not this dashboard's: no visit row, no named source here. + const answersAreOwn = user?.onboarding?.needs.recordTarget !== "someone-else"; + const visitAsked = + answersAreOwn && user?.onboarding?.needs.visit === "within-a-month"; const { data: upcomingVisitCount } = useQuery({ queryKey: queryKeys.onboardingUpcomingVisits(), queryFn: async () => @@ -314,8 +325,12 @@ export function GettingStartedChecklist() { // The named source: "Connect Oura" rather than "Connect a data source" // for somebody who said their readings come from one. - const namedSource = - user?.onboarding?.needs.sources.find(isBrowserConnectableSource) ?? null; + const namedSource = answersAreOwn + ? (user?.onboarding?.needs.sources.find(isBrowserConnectableSource) ?? null) + : null; + const managedProfileCount = (user?.accountAccess?.accounts ?? []).filter( + (entry) => entry.recordKind === "managed", + ).length; const medicationCount = medsData?.length ?? 0; const dataSourceConnected = (integrationsData?.integrations ?? []).some( @@ -342,6 +357,7 @@ export function GettingStartedChecklist() { insightsConfigured, dismissedIds, upcomingVisitCount: upcomingVisitCount ?? 0, + managedProfileCount, // 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 @@ -362,6 +378,7 @@ export function GettingStartedChecklist() { insightsConfigured, dismissedIds, upcomingVisitCount, + managedProfileCount, ], ); diff --git a/src/lib/onboarding/__tests__/checklist.test.ts b/src/lib/onboarding/__tests__/checklist.test.ts index 42f7edaaf..6d19e8fa1 100644 --- a/src/lib/onboarding/__tests__/checklist.test.ts +++ b/src/lib/onboarding/__tests__/checklist.test.ts @@ -57,6 +57,7 @@ function inputs( insightsConfigured: false, dismissedIds: new Set(), upcomingVisitCount: 0, + managedProfileCount: 0, onboarding: null, ...overrides, }; @@ -168,6 +169,45 @@ describe("upcomingVisitCountFrom (the visits list's wire shape)", () => { }); }); +describe("answers given for somebody else's record (v1.39 C2)", () => { + it("adds the profile row and reads none of the child's answers as the guardian's", () => { + const items = buildChecklist( + inputs({ + onboarding: onboardingState( + {}, + { + recordTarget: "someone-else", + visit: "within-a-month", + sources: ["oura"], + }, + ), + }), + ); + const ids = items.map((i) => i.id); + expect(ids).toContain("managedProfile"); + expect(ids).not.toContain("visit"); + expect(items.find((i) => i.id === "managedProfile")).toMatchObject({ + done: false, + href: "/settings/access", + }); + expect(ids[1]).toBe("managedProfile"); + }); + + it("is done once a managed profile exists, and offered to both as well", () => { + const both = buildChecklist( + inputs({ + onboarding: onboardingState({}, { recordTarget: "both" }), + managedProfileCount: 1, + }), + ); + expect(both.find((i) => i.id === "managedProfile")?.done).toBe(true); + const me = buildChecklist( + inputs({ onboarding: onboardingState({}, { recordTarget: "me" }) }), + ); + expect(me.map((i) => i.id)).not.toContain("managedProfile"); + }); +}); + describe("the visit row (v1.39 C2)", () => { it("exists only for an answer that named a visit within a month", () => { const asked = buildChecklist( @@ -209,6 +249,7 @@ describe("the visit row (v1.39 C2)", () => { "dataSource", "notifications", "insights", + "managedProfile", ]); }); }); @@ -253,6 +294,7 @@ describe("visibleChecklist + checklistProgress", () => { insightsConfigured: false, dismissedIds: new Set(), upcomingVisitCount: 0, + managedProfileCount: 0, }); const progress = checklistProgress(items); expect(progress.percent).toBe(0); diff --git a/src/lib/onboarding/checklist.ts b/src/lib/onboarding/checklist.ts index 074e5d052..c0fda5294 100644 --- a/src/lib/onboarding/checklist.ts +++ b/src/lib/onboarding/checklist.ts @@ -23,13 +23,17 @@ export const CHECKLIST_ITEM_IDS = [ // v1.39 (C2) — present only for an answer that named a visit within a // month (design spec §After the flow: "prepare the visit"). "visit", + // v1.39 (C2) — "someone I look after" (or "both") with no managed profile + // yet: the answers were given for a record that does not exist, and this + // row is where it gets created. + "managedProfile", ] as const; export type ChecklistItemId = (typeof CHECKLIST_ITEM_IDS)[number]; /** The rows every record has; the visit row joins only when asked for. */ export const CHECKLIST_BASE_ITEM_IDS: readonly ChecklistItemId[] = - CHECKLIST_ITEM_IDS.filter((id) => id !== "visit"); + CHECKLIST_ITEM_IDS.filter((id) => id !== "visit" && id !== "managedProfile"); export interface ChecklistItem { id: ChecklistItemId; @@ -84,6 +88,11 @@ export interface ChecklistInputs { * question passes zero. */ upcomingVisitCount: number; + /** + * v1.39 (C2) — managed profiles this account looks after, for the row that + * asks a guardian to create the one their answers were about. + */ + managedProfileCount: number; /** * v1.39 (C1) — the needs-based setup state from `GET /api/auth/me`, or null * for a record that never entered the flow. @@ -116,6 +125,19 @@ export function checklistOrderFromNeeds( if (!promoted.includes(id)) promoted.push(id); }; + // Answers given for somebody else's record say nothing about the + // guardian's own dashboard: the one row they promote is the profile. + if (needs.recordTarget === "someone-else") { + promote("managedProfile"); + return [ + "profile", + ...promoted, + ...CHECKLIST_ITEM_IDS.filter( + (id) => id !== "profile" && !promoted.includes(id), + ), + ]; + } + if (needs.medication === "yes" || needs.medication === "sometimes") { promote("medication"); } @@ -185,7 +207,11 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] { if (!inputs.onboarding) return items; // The visit row exists only for the answer that asked for it; every other // record keeps the six rows it had. Done once a visit is on the calendar. - if (inputs.onboarding.needs.visit === "within-a-month") { + const target = inputs.onboarding.needs.recordTarget; + if ( + target !== "someone-else" && + inputs.onboarding.needs.visit === "within-a-month" + ) { items.push({ id: "visit", done: inputs.upcomingVisitCount >= 1, @@ -193,6 +219,16 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] { dismissed: inputs.dismissedIds.has("visit"), }); } + // The profile the answers were about. Offered to "both" as well, which + // the spec says gets the profile at the end. + if (target === "someone-else" || target === "both") { + items.push({ + id: "managedProfile", + done: inputs.managedProfileCount >= 1, + href: "/settings/access", + dismissed: inputs.dismissedIds.has("managedProfile"), + }); + } // Ordered from the answers only once the flow has been confirmed: before // that the answers are still being given, and re-ordering the dashboard // under someone mid-question would be movement they did not ask for. diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 49080d36f..ea361538c 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -238,7 +238,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "onboarding.answers.invalid", "onboarding.answers.rateLimited", "onboarding.complete.invalid", - "onboarding.complete.notGuardian", + "onboarding.complete.recordNotFound", "onboarding.complete.recordTargetMismatch", "onboarding.disclaimer.invalid", "onboarding.restart.invalid", diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts index f7fd5616b..d306cdc26 100644 --- a/src/lib/openapi/routes/onboarding.ts +++ b/src/lib/openapi/routes/onboarding.ts @@ -213,7 +213,7 @@ const disclaimerAckResponse = z const onboardingCompleteRequest = onboardingCompleteSchema.meta({ id: "OnboardingCompleteRequest", description: - "Optional profile fields to save alongside the completion stamp. Every field is optional and only a truthy value is written — sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing it. There is no way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new Date(...)`: an unparseable value is silently ignored, not refused.", + "The completion stamp's body. `managedRecordId` (v1.39) names the managed record the answers were given for — see the field. The profile fields are the legacy half: every one optional, only a truthy value is written — sending `heightCm: 0` or an empty `displayName` leaves the column alone rather than clearing it, and there is no way to CLEAR a field through this endpoint. `dateOfBirth` is a free string parsed with `new Date(...)`: an unparseable value is silently ignored, not refused. The web flow writes its profile through `PUT /api/auth/profile` instead.", }); export const onboardingPaths: NonNullable = { @@ -302,7 +302,7 @@ export const onboardingPaths: NonNullable = { tags: ["Onboarding"], summary: "Stamp onboarding as complete, and derive the module map", description: - "Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nv1.39 — this is the needs-based flow's confirm endpoint. For a record that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions again. The merge is one-directional — a module the answers name is switched on, a module they do not name is switched off only where the record does not already carry an explicit on and holds no data in that domain. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route sets to `true` when the cycle area was chosen and never to `false`. In the same derivation the dashboard order is seeded from the answers — the tiles of the chosen areas (and the medication tile for a schedule) move to the front and are made visible — but only while the layout column is still unset, so a person who already arranged their tiles is never clobbered. A half-answered flow derives nothing and is not stamped as complete.\n\nThe response carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement alone — it stamps the completion whatever the answers say, re-stamps on every call rather than refusing a second one, and enforces no rate limit of its own. The legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits.\n\nCookie or wildcard Bearer; `userId` is never read from the body.", + "Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending cookie so the next navigation stops redirecting to `/onboarding`.\n\nv1.39 — this is the needs-based flow's confirm endpoint. For a record that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears the marker when the person asks for the questions again. The merge is one-directional — a module the answers name is switched on, a module they do not name is switched off only where the record does not already carry an explicit on and holds no data in that domain. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route sets to `true` when the cycle area was chosen and never to `false`. In the same derivation the dashboard order is seeded from the answers — the tiles of the chosen areas (and the medication tile for a schedule) move to the front and are made visible — but only while the layout column is still unset, so a person who already arranged their tiles is never clobbered. A half-answered flow derives nothing and is not stamped as complete.\n\nThe response carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the fixed acknowledgement alone — it stamps the completion whatever the answers say, re-stamps on every call rather than refusing a second one, and enforces no rate limit of its own. The legacy half writes no audit row; the needs half writes one (`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits.\n\nCookie or wildcard Bearer; `userId` is never read from the body.\n\n`managedRecordId` — \"someone I look after\". The answers were given for the profile the confirm screen created, so the derivation and the seed land on THAT record through the same record-keyed write the guardian's modules route uses, its setup row is written as finished, and the caller's own record is stamped complete and latched with its map untouched. A managed record the caller does not actively guard, a record that is not a managed profile, or an id that does not exist all answer 404 with nothing stamped; a body naming a record when Q1 was not `someone-else` is a 422. With Q1 = `someone-else` and NO record id, the flow completes and latches without deriving anything onto anybody: there is no record the answers describe yet, and they never describe the guardian's.", requestBody: { required: true, content: { "application/json": { schema: onboardingCompleteRequest } }, @@ -323,6 +323,11 @@ export const onboardingPaths: NonNullable = { }, }, }, + "404": { + description: + "`managedRecordId` names no managed profile the caller actively guards — missing, not a managed record, or not theirs; one answer for all three. `meta.errorCode` = `onboarding.complete.recordNotFound`. Nothing was stamped.", + content: { "application/json": { schema: errorEnvelope } }, + }, "413": { description: "Body exceeds 64 KiB.", content: { "application/json": { schema: errorEnvelope } }, @@ -334,7 +339,7 @@ export const onboardingPaths: NonNullable = { ...stdResponses, "422": { description: - "The body did not validate. `meta.errorCode` = `onboarding.complete.invalid`. Single-message, not the multi-issue envelope.", + "The body did not validate (`meta.errorCode` = `onboarding.complete.invalid`; single-message, not the multi-issue envelope), or `managedRecordId` was sent for answers that were not given for somebody else's record — Q1 was `me` or `both` (`meta.errorCode` = `onboarding.complete.recordTargetMismatch`; nothing stamped).", content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/validations/onboarding.ts b/src/lib/validations/onboarding.ts index 4fee80809..4bb14265b 100644 --- a/src/lib/validations/onboarding.ts +++ b/src/lib/validations/onboarding.ts @@ -32,7 +32,15 @@ export const onboardingCompleteSchema = z.object({ * 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(), + managedRecordId: z + .string() + .trim() + .min(1) + .max(64) + .optional() + .describe( + "The managed record the setup answers were given FOR — the profile \"someone I look after\" created on the confirm screen. When present, the module map and the dashboard order derived from the caller's answers are applied to THAT record and its own setup row is written as finished; the caller's own record is stamped complete and its derivation latched without any module being touched. Honoured only for a managed profile the caller actively guards (otherwise 404 `onboarding.complete.recordNotFound`) and only when the answer to Q1 was `someone-else` (otherwise 422 `onboarding.complete.recordTargetMismatch`); nothing is stamped on a refusal. Absent with Q1 = `someone-else`, the flow completes without deriving anything anywhere.", + ), displayName: z.string().trim().min(1).max(50).optional(), heightCm: z.number().min(50).max(300).optional(), dateOfBirth: z.string().optional(), diff --git a/tests/integration/onboarding-needs.test.ts b/tests/integration/onboarding-needs.test.ts index b46528eb9..962af237c 100644 --- a/tests/integration/onboarding-needs.test.ts +++ b/tests/integration/onboarding-needs.test.ts @@ -609,7 +609,44 @@ describe("POST /api/onboarding/complete for somebody else's record", () => { expect(await modulePrefs(guardian.id)).toEqual({}); }); - it("refuses a record the caller does not guard, and touches nothing", async () => { + it("derives nowhere when the answers were for somebody else and no profile exists", async () => { + // H1' — "Finish without the profile" (no second factor, or a change of + // mind). There is no record the answers describe, and they must not + // describe the guardian's: the flow completes and latches, nothing is + // derived or seeded on the guardian, whatever the client sends. + const guardian = await makeUser("guardian-no-profile"); + await signIn(guardian.id); + await patchAnswer({ step: "who", recordTarget: "someone-else" }); + await patchAnswer({ step: "areas", areas: ["blood-pressure"] }); + await patchAnswer({ step: "medication", medication: "yes" }); + await patchAnswer({ step: "sources", sources: ["manual"] }); + await patchAnswer({ step: "visit", visit: "within-a-month" }); + await patchAnswer({ step: "units", status: "skipped" }); + + const res = await postComplete(); + expect(res.status).toBe(200); + expect(await modulePrefs(guardian.id)).toEqual({}); + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: guardian.id }, + select: { dashboardWidgetsJson: true, onboardingCompletedAt: true }, + }); + expect(row.dashboardWidgetsJson).toBeNull(); + expect(row.onboardingCompletedAt).not.toBeNull(); + const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({ + where: { userId: guardian.id }, + }); + expect(own.completedAt).not.toBeNull(); + // Latched: a second confirm, with or without a record, derives nothing. + expect(own.modulesDerivedAt).not.toBeNull(); + expect((await postComplete()).status).toBe(200); + expect(await modulePrefs(guardian.id)).toEqual({}); + }); + + it("answers 404 for a record the caller does not guard, and touches nothing", async () => { + // M7 — a record addressed by id answers 404 for missing, not managed and + // not guarded alike, as `GET /api/managed-profiles/{id}` does through + // the same helper: one answer, no existence oracle. This asserted 403 + // once; the id-addressed convention is 404. const { guardian } = await guardianWithChild(); const stranger = await makeUser("stranger-guardian"); const { createManagedProfile } = @@ -623,7 +660,10 @@ describe("POST /api/onboarding/complete for somebody else's record", () => { }); const res = await postComplete({ managedRecordId: theirs.id }); - expect(res.status).toBe(403); + expect(res.status).toBe(404); + expect( + (await postComplete({ managedRecordId: "no-such-record" })).status, + ).toBe(404); expect(await modulePrefs(theirs.id)).toEqual({}); expect(await modulePrefs(guardian.id)).toEqual({}); const own = await getPrismaClient().onboardingRecord.findUniqueOrThrow({