diff --git a/prisma/migrations/0338_onboarding_needs/migration.sql b/prisma/migrations/0338_onboarding_needs/migration.sql new file mode 100644 index 000000000..244fd1800 --- /dev/null +++ b/prisma/migrations/0338_onboarding_needs/migration.sql @@ -0,0 +1,22 @@ +-- v1.39 (C1) — the needs-based onboarding's per-record state. +-- +-- One row per record (an account or a managed profile), created lazily by the +-- first answered step. Nothing here is encrypted: the three JSON columns hold +-- closed enum vocabularies rather than free text. +CREATE TABLE "onboarding_records" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "needs_json" JSONB, + "steps_json" JSONB, + "first_result_json" JSONB, + "modules_derived_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "onboarding_records_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "onboarding_records_user_id_key" ON "onboarding_records"("user_id"); + +ALTER TABLE "onboarding_records" ADD CONSTRAINT "onboarding_records_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0c6e0a00a..0c3524930 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -995,6 +995,12 @@ model User { // seeded catalogue). cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") + // v1.39 — the needs-based onboarding's answers and step ledger for THIS + // 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? + // v1.15.20 — user-authored "about me" context for the AI surfaces // (1:1, encrypted free text). See `UserHealthProfile`. healthProfile UserHealthProfile? @@ -8298,3 +8304,63 @@ model VaccinationDocumentLink { @@index([userId]) @@map("vaccination_document_links") } + +// ─── Onboarding (needs-based flow) ───────────────────────────── + +/// v1.39 (C1) — what a record answered when it was set up, and how far the +/// flow got. +/// +/// One row per RECORD, not per person: a managed profile is an ordinary `User` +/// row with no credentials, so keying on `userId` is what lets a guardian run +/// a child's setup and have the answers land on the child's record. Created +/// lazily by the first answer; an account that never entered the flow has no +/// row and reads as "nothing answered, nine steps pending". +/// +/// The three JSON columns hold closed enum vocabularies, not free text, so +/// nothing here is encrypted: the encryption convention in this repository +/// covers free text a person wrote (`UserHealthProfile.aboutMeEncrypted`, the +/// Coach transcript, the emergency note) and identifiers (the KVNR), and an +/// answer drawn from nine fixed chips is neither. It is the same class of +/// value as `modulePreferencesJson` and `onboardingGoals`, both of which sit +/// in plaintext beside it. Readers are fail-soft rather than schema-validated +/// at the database edge — see `src/lib/onboarding/needs.ts`. +model OnboardingRecord { + id String @id @default(cuid()) + userId String @unique @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// The answers as given: `{ recordTarget, areas[], medication, sources[], + /// visit, units }`. Read by `parseOnboardingNeeds`, published on + /// `GET /api/auth/me` as `onboarding.needs`, and the input the module + /// derivation reads once at confirm time. + needsJson Json? @map("needs_json") + + /// The ordered steps as `[{ id, status }]` with `status` one of + /// `pending | done | skipped`. Stored as a status lookup — the ORDER on the + /// wire is always this release's `ONBOARDING_STEP_IDS`, so a step added or + /// retired needs no backfill. + stepsJson Json? @map("steps_json") + + /// The one task the flow offered at the end, and whether it produced its + /// result: `{ task, target, completedAt }`. Null until the first-result + /// step names one. + firstResultJson Json? @map("first_result_json") + + /// When the module map was derived from `needsJson`. The derivation is + /// once-only per run of the flow: `POST /api/onboarding/complete` skips it + /// while this is set, and `POST /api/onboarding/restart` clears it so a + /// re-run may derive again. Without it a repeated confirm would re-apply + /// the answers over decisions taken in Settings since. + modulesDerivedAt DateTime? @map("modules_derived_at") + + /// When the needs flow was completed. Deliberately NOT + /// `User.onboardingCompletedAt`: that column gates the first-run redirect in + /// `src/proxy.ts` and keeps that meaning, while this one may be cleared by + /// "Set up again" without pushing the person back through the redirect. + completedAt DateTime? @map("completed_at") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("onboarding_records") +} diff --git a/src/lib/modules/__tests__/onboarding-needs-map.test.ts b/src/lib/modules/__tests__/onboarding-needs-map.test.ts new file mode 100644 index 000000000..79dbaa825 --- /dev/null +++ b/src/lib/modules/__tests__/onboarding-needs-map.test.ts @@ -0,0 +1,215 @@ +/** + * v1.39 (C1) — the needs → modules mapping, and the merge that protects a + * decision somebody already made. + * + * The mapping is the one piece of the flow with a consequence that outlives + * the screens: a module switched off here disappears from the navigation until + * somebody finds it again in Settings. So the properties below are stated as + * properties over the whole registry rather than as a table of examples — a + * module key added to `MODULE_KEYS` next year is covered by them on the day it + * lands, and an area that stops naming anything fails here rather than + * shipping as a chip with no effect. + */ +import { describe, expect, it } from "vitest"; + +import { + deriveOnboardingModuleDefaults, + isModuleKey, + mergeDerivedModulePreferences, + MODULE_REGISTRY, + ONBOARDING_ALWAYS_ON_MODULES, + ONBOARDING_AREA_KEYS, + ONBOARDING_AREA_MODULES, + OWNED_MODULE_KEYS, + type OnboardingModuleNeeds, +} from "../registry"; + +function needs( + overrides: Partial = {}, +): OnboardingModuleNeeds { + return { + recordTarget: "me", + areas: [], + medication: "no", + visit: "no", + ...overrides, + }; +} + +describe("the area → module map", () => { + it("gives every area at least one module", () => { + expect(ONBOARDING_AREA_KEYS.length).toBe(9); + for (const area of ONBOARDING_AREA_KEYS) { + expect( + ONBOARDING_AREA_MODULES[area]?.length ?? 0, + `area \`${area}\` names no module, so ticking it changes nothing`, + ).toBeGreaterThan(0); + } + }); + + it("names only real module keys", () => { + for (const area of ONBOARDING_AREA_KEYS) { + for (const key of ONBOARDING_AREA_MODULES[area]) { + expect(isModuleKey(key), `\`${key}\` is not a module key`).toBe(true); + } + } + }); + + it("never names the Coach, which the flow deliberately does not offer", () => { + const named = ONBOARDING_AREA_KEYS.flatMap( + (area) => ONBOARDING_AREA_MODULES[area], + ); + expect(named).not.toContain("coach"); + }); +}); + +describe("deriveOnboardingModuleDefaults", () => { + it("answers for every directly-owned module and for none of the delegated ones", () => { + const derived = deriveOnboardingModuleDefaults(needs()); + expect(Object.keys(derived.preferences).sort()).toEqual( + [...OWNED_MODULE_KEYS].sort(), + ); + expect(derived.preferences).not.toHaveProperty("cycle"); + expect(derived.preferences).not.toHaveProperty("coach"); + for (const key of OWNED_MODULE_KEYS) { + expect(MODULE_REGISTRY[key].delegatesTo).toBeUndefined(); + } + }); + + it("keeps the always-on modules on however the questions are answered", () => { + const answers: OnboardingModuleNeeds[] = [ + needs(), + needs({ areas: [...ONBOARDING_AREA_KEYS], medication: "yes" }), + needs({ recordTarget: "someone-else", visit: "within-a-month" }), + needs({ recordTarget: "both", medication: "sometimes", visit: "later" }), + ]; + for (const answer of answers) { + const { preferences } = deriveOnboardingModuleDefaults(answer); + for (const key of ONBOARDING_ALWAYS_ON_MODULES) { + expect( + preferences[key as keyof typeof preferences], + `always-on module \`${key}\` was derived off`, + ).toBe(true); + } + } + }); + + it("switches on exactly what the chosen areas name", () => { + const { preferences, cycleTracking } = deriveOnboardingModuleDefaults( + needs({ areas: ["glucose", "sleep", "mood"] }), + ); + expect(preferences.glucose).toBe(true); + expect(preferences.sleep).toBe(true); + expect(preferences.recovery).toBe(true); + expect(preferences.mood).toBe(true); + expect(preferences.mentalHealth).toBe(true); + expect(preferences.labs).toBe(false); + expect(preferences.workouts).toBe(false); + expect(preferences.illness).toBe(false); + expect(cycleTracking).toBe(false); + }); + + it("reports the cycle area separately, because the module blob does not own it", () => { + expect(deriveOnboardingModuleDefaults(needs({ areas: ["cycle"] }))).toEqual( + expect.objectContaining({ cycleTracking: true }), + ); + expect(deriveOnboardingModuleDefaults(needs()).cycleTracking).toBe(false); + }); + + it("follows the medication answer", () => { + for (const answer of ["yes", "sometimes"] as const) { + expect( + deriveOnboardingModuleDefaults(needs({ medication: answer })) + .preferences.medications, + ).toBe(true); + } + expect( + deriveOnboardingModuleDefaults(needs({ medication: "no" })).preferences + .medications, + ).toBe(false); + }); + + it("switches the doctor report on for any visit that is actually coming", () => { + for (const visit of ["within-a-month", "later"] as const) { + expect( + deriveOnboardingModuleDefaults(needs({ visit })).preferences + .doctorReport, + ).toBe(true); + } + expect( + deriveOnboardingModuleDefaults(needs({ visit: "no" })).preferences + .doctorReport, + ).toBe(false); + }); + + it("switches the immunization log on for a record somebody else runs", () => { + for (const recordTarget of ["someone-else", "both"] as const) { + expect( + deriveOnboardingModuleDefaults(needs({ recordTarget })).preferences + .vaccinations, + ).toBe(true); + } + expect( + deriveOnboardingModuleDefaults(needs({ recordTarget: "me" })).preferences + .vaccinations, + ).toBe(false); + }); + + it("leaves the opt-in surfaces off unless something asks for them", () => { + const { preferences } = deriveOnboardingModuleDefaults( + needs({ areas: [...ONBOARDING_AREA_KEYS] }), + ); + expect(preferences.mcp).toBe(false); + expect(preferences.nutrients).toBe(false); + expect(preferences.environment).toBe(false); + }); +}); + +describe("mergeDerivedModulePreferences", () => { + it("never turns off a module the person switched on by hand", () => { + const { preferences } = deriveOnboardingModuleDefaults(needs()); + expect(preferences.mcp).toBe(false); + expect(preferences.labs).toBe(false); + + const merged = mergeDerivedModulePreferences( + { mcp: true, labs: true }, + preferences, + ); + expect(merged.mcp).toBe(true); + expect(merged.labs).toBe(true); + }); + + it("does turn off a module the record only had by default", () => { + const { preferences } = deriveOnboardingModuleDefaults(needs()); + const merged = mergeDerivedModulePreferences({}, preferences); + expect(merged.labs).toBe(false); + expect(merged.workouts).toBe(false); + }); + + it("switches on what the answers named even where the record had it off", () => { + const { preferences } = deriveOnboardingModuleDefaults( + needs({ areas: ["labs"] }), + ); + const merged = mergeDerivedModulePreferences({ labs: false }, preferences); + expect(merged.labs).toBe(true); + }); + + it("drops a stored key that is not a module", () => { + const { preferences } = deriveOnboardingModuleDefaults(needs()); + const merged = mergeDerivedModulePreferences( + { weight: false, nonsense: true }, + preferences, + ); + expect(merged).not.toHaveProperty("weight"); + expect(merged).not.toHaveProperty("nonsense"); + }); + + it("leaves the two delegated keys alone in both directions", () => { + const { preferences } = deriveOnboardingModuleDefaults( + needs({ areas: ["cycle"] }), + ); + const merged = mergeDerivedModulePreferences({}, preferences); + expect(merged).not.toHaveProperty("cycle"); + expect(merged).not.toHaveProperty("coach"); + }); +}); diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 7025e129f..b2346dc6e 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -423,3 +423,189 @@ export function moduleDelegatesTo( export function isOptInModule(key: ModuleKey): boolean { return MODULE_REGISTRY[key].optIn === true; } + +/* ─── Needs-based onboarding: the areas a person names → the modules ───────── */ + +/** + * v1.39 (C1) — the nine areas the needs questionnaire offers (design spec + * §"The questions", Q2), as stable slugs. + * + * They live here rather than in the flow because the answer only means + * something as a set of modules, and the mapping below is what gives it that + * meaning. Putting the map next to `MODULE_KEYS` is what keeps a module key + * renamed or retired from leaving a dangling area behind: the record type + * makes it a compile error rather than a silently empty selection. + * + * All nine stay visible, cycle included — it is one chip, and hiding it behind + * recorded sex would make the flow guess at something the person is being + * asked about anyway. + */ +export const ONBOARDING_AREA_KEYS = [ + "blood-pressure", + "weight-body", + "glucose", + "sleep", + "mood", + "cycle", + "activity", + "labs", + "illness", +] as const; + +export type OnboardingAreaKey = (typeof ONBOARDING_AREA_KEYS)[number]; + +/** + * The area → module map. Every area names at least one module; blood pressure + * and weight name only `insights`, because the readings themselves are CORE + * (weight / blood pressure / pulse have no key and can never be switched off) + * and the narrative layer is the only thing an answer can decide there. + * + * `cycle` is in the map and is NOT written through `modulePreferencesJson`: + * that key delegates to `CycleProfile.cycleTrackingEnabled`, so the derivation + * reports it separately (see {@link DerivedModuleDefaults.cycleTracking}) and + * the caller writes the real column. `coach` appears nowhere: the flow never + * talks to an AI provider, and turning the Coach on is a Settings decision + * with a cost attached to it. + */ +export const ONBOARDING_AREA_MODULES: Readonly< + Record +> = Object.freeze({ + "blood-pressure": ["insights"], + "weight-body": ["insights"], + glucose: ["glucose", "insights"], + sleep: ["sleep", "recovery"], + // "Mood and mental wellbeing" is one chip over two modules: the daily mood + // entry and the WHO-5 / PHQ-9 / GAD-7 check-in live on the same surface as + // far as the person answering is concerned. + mood: ["mood", "mentalHealth"], + cycle: ["cycle"], + activity: ["workouts"], + labs: ["labs"], + illness: ["illness"], +}); + +/** + * Modules that stay on whatever the answers say (design spec §"Needs → modules + * → first result"): the measurement engine is CORE and has no key, and these + * three are the surfaces a record is unusable without — the narrative layer, + * the badges, and the document vault. + */ +export const ONBOARDING_ALWAYS_ON_MODULES: readonly ModuleKey[] = [ + "insights", + "achievements", + "inboundDocuments", +]; + +/** + * The answers the module derivation actually reads. The flow stores more than + * this (the sources, the units); nothing else changes which modules are on. + * + * The literal unions are written here rather than imported so this file keeps + * depending on nothing. `src/lib/onboarding/needs.ts` declares the same + * vocabulary with `satisfies`, and the derivation call site is typed against + * this interface — a value that drifts apart fails to compile there. + */ +export interface OnboardingModuleNeeds { + recordTarget: "me" | "someone-else" | "both"; + areas: readonly OnboardingAreaKey[]; + medication: "yes" | "no" | "sometimes"; + visit: "within-a-month" | "later" | "no"; +} + +/** A toggleable module whose state `modulePreferencesJson` really owns. */ +export type OwnedModuleKey = Exclude; + +/** Toggleable modules minus the two delegated keys, in registry order. */ +export const OWNED_MODULE_KEYS: readonly OwnedModuleKey[] = MODULE_KEYS.filter( + (key): key is OwnedModuleKey => + MODULE_REGISTRY[key].delegatesTo === undefined, +); + +export interface DerivedModuleDefaults { + /** + * An explicit value for every directly-owned module: `true` for what the + * answers named, `false` for the rest. Explicit `false` rather than absence + * is the "ordering versus removal" decision from the design spec — the + * stored map is a disabled allowlist, so absence would mean ON. + */ + preferences: Record; + /** + * Whether the answers asked for cycle tracking. Only ever `true` here: the + * gate derives an unanswered cycle from recorded sex, and an area the person + * did not tick is not a statement that they want it switched off. The caller + * writes `CycleProfile.cycleTrackingEnabled` when this is true and leaves + * the column alone when it is not. + */ + cycleTracking: boolean; +} + +/** + * Turn the needs answers into the module map the record starts from. + * + * Pure and total: every directly-owned key gets a value, so the caller never + * has to reason about which keys the answers happened to mention. It says + * nothing about what the record already holds — merging is + * {@link mergeDerivedModulePreferences}, deliberately a second step, because + * "what the answers imply" and "what this person already decided by hand" are + * different questions and only one of them may be overwritten. + */ +export function deriveOnboardingModuleDefaults( + needs: OnboardingModuleNeeds, +): DerivedModuleDefaults { + const on = new Set(ONBOARDING_ALWAYS_ON_MODULES); + + for (const area of needs.areas) { + for (const key of ONBOARDING_AREA_MODULES[area] ?? []) on.add(key); + } + if (needs.medication === "yes" || needs.medication === "sometimes") { + on.add("medications"); + } + // A visit that is coming at all is what the doctor report is for; "no" is + // the only answer that leaves the export surface off. + if (needs.visit === "within-a-month" || needs.visit === "later") { + on.add("doctorReport"); + } + // A record somebody else runs is usually a child's or a parent's, and the + // immunization log is the part of it that matters most there. + if (needs.recordTarget === "someone-else" || needs.recordTarget === "both") { + on.add("vaccinations"); + } + + const preferences = {} as Record; + for (const key of OWNED_MODULE_KEYS) preferences[key] = on.has(key); + + return { preferences, cycleTracking: on.has("cycle") }; +} + +/** + * Merge a derived map into the one the record already holds, without ever + * retracting a decision the person made by hand. + * + * The rule is one-directional: a derived `true` always lands, a derived + * `false` lands only where the stored map does not already carry an explicit + * `true`. Someone who switched the MCP endpoint on, then ran the flow again + * and did not tick anything that implies it, keeps it on — the questionnaire + * orders a record, it does not undo it. + * + * Keys the stored map carries that are not module keys are dropped; the caller + * is expected to have normalised it (`normalisePrefs`), and this is the second + * belt on the same trousers rather than a place to be clever. + */ +export function mergeDerivedModulePreferences( + existing: Readonly>, + derived: Readonly>, +): Record { + const merged: Record = {}; + for (const [key, value] of Object.entries(existing)) { + if (isModuleKey(key)) merged[key] = value; + } + for (const [key, value] of Object.entries(derived)) { + if (value) { + merged[key] = true; + continue; + } + if (merged[key] === true) continue; // switched on by hand — never retracted + merged[key] = false; + } + return merged; +} diff --git a/src/lib/onboarding/__tests__/needs.test.ts b/src/lib/onboarding/__tests__/needs.test.ts new file mode 100644 index 000000000..a070ebda8 --- /dev/null +++ b/src/lib/onboarding/__tests__/needs.test.ts @@ -0,0 +1,262 @@ +/** + * v1.39 (C1) — the stored onboarding state: reading it back, and applying one + * answered step to it. + * + * The two claims worth pinning here are the ones the route promises on the + * wire. `PATCH /api/onboarding/answers` says it is idempotent, and the reason + * it can say so is that the whole computation is this pure function — so the + * property is checkable without a database. The readers say they are + * fail-soft, and the reason that matters is that they sit on the account + * payload that rides every app boot: a column written by a release that named + * an area this one retired must not 500 the shell. + */ +import { describe, expect, it } from "vitest"; + +import { applyOnboardingAnswer } from "../needs-apply"; +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + isOnboardingSettled, + ONBOARDING_STEP_IDS, + parseOnboardingFirstResult, + parseOnboardingNeeds, + parseOnboardingSteps, + type OnboardingStateDto, +} from "../needs"; + +function blank() { + return { + needs: emptyOnboardingNeeds(), + steps: defaultOnboardingSteps(), + firstResult: null, + }; +} + +const AT = new Date("2026-09-10T08:00:00.000Z"); + +describe("parseOnboardingSteps", () => { + it("returns the full ordered nine whatever the column holds", () => { + expect(parseOnboardingSteps(null).map((s) => s.id)).toEqual([ + ...ONBOARDING_STEP_IDS, + ]); + expect( + parseOnboardingSteps(null).every((s) => s.status === "pending"), + ).toBe(true); + }); + + it("keeps a stored status and ignores an id or status it does not know", () => { + const parsed = parseOnboardingSteps([ + { id: "areas", status: "done" }, + { id: "visit", status: "skipped" }, + { id: "no-such-step", status: "done" }, + { id: "who", status: "half-done" }, + ]); + expect(parsed.find((s) => s.id === "areas")?.status).toBe("done"); + expect(parsed.find((s) => s.id === "visit")?.status).toBe("skipped"); + expect(parsed.find((s) => s.id === "who")?.status).toBe("pending"); + expect(parsed.map((s) => s.id)).toEqual([...ONBOARDING_STEP_IDS]); + }); +}); + +describe("parseOnboardingNeeds", () => { + it("reads nothing out of a corrupt column rather than throwing", () => { + expect(parseOnboardingNeeds("not an object")).toEqual( + emptyOnboardingNeeds(), + ); + expect(parseOnboardingNeeds({ areas: "sleep" }).areas).toEqual([]); + }); + + it("drops unknown answers and de-duplicates the lists", () => { + const parsed = parseOnboardingNeeds({ + recordTarget: "nobody", + areas: ["sleep", "sleep", "astrology"], + medication: "yes", + sources: ["withings", "carrier-pigeon"], + visit: "later", + units: { glucoseUnit: "mmol/L", unitPreference: "furlongs" }, + }); + expect(parsed.recordTarget).toBeNull(); + expect(parsed.areas).toEqual(["sleep"]); + expect(parsed.medication).toBe("yes"); + expect(parsed.sources).toEqual(["withings"]); + expect(parsed.visit).toBe("later"); + expect(parsed.units).toEqual({ + glucoseUnit: "mmol/L", + unitPreference: null, + }); + }); +}); + +describe("parseOnboardingFirstResult", () => { + it("is null without a known task", () => { + expect(parseOnboardingFirstResult(null)).toBeNull(); + expect(parseOnboardingFirstResult({ task: "invent-a-cure" })).toBeNull(); + }); + + it("reads the task, its target and its stamp", () => { + expect( + parseOnboardingFirstResult({ + task: "connect-source", + target: "withings", + completedAt: "2026-09-10T08:00:00.000Z", + }), + ).toEqual({ + task: "connect-source", + target: "withings", + completedAt: "2026-09-10T08:00:00.000Z", + }); + }); +}); + +describe("applyOnboardingAnswer", () => { + it("marks the step done and records the answer", () => { + const next = applyOnboardingAnswer( + blank(), + { step: "areas", areas: ["sleep", "labs"] }, + AT, + ); + expect(next.needs.areas).toEqual(["sleep", "labs"]); + expect(next.steps.find((s) => s.id === "areas")?.status).toBe("done"); + expect(next.steps.find((s) => s.id === "who")?.status).toBe("pending"); + }); + + it("is idempotent — the same body twice is the same state", () => { + const once = applyOnboardingAnswer( + blank(), + { step: "medication", medication: "yes" }, + AT, + ); + const twice = applyOnboardingAnswer( + once, + { step: "medication", medication: "yes" }, + new Date("2026-09-11T09:00:00.000Z"), + ); + expect(twice).toEqual(once); + }); + + it("keeps the first-result stamp across a replay rather than re-taking it", () => { + const first = applyOnboardingAnswer( + blank(), + { + step: "first-result", + firstResult: { task: "log-reading", target: "sleep", completed: true }, + }, + AT, + ); + expect(first.firstResult?.completedAt).toBe(AT.toISOString()); + + const replay = applyOnboardingAnswer( + first, + { + step: "first-result", + firstResult: { task: "log-reading", target: "sleep", completed: true }, + }, + new Date("2026-09-12T10:00:00.000Z"), + ); + expect(replay.firstResult?.completedAt).toBe(AT.toISOString()); + }); + + it("re-offers a DIFFERENT task without inheriting the old stamp", () => { + const first = applyOnboardingAnswer( + blank(), + { + step: "first-result", + firstResult: { task: "log-reading", completed: true }, + }, + AT, + ); + const second = applyOnboardingAnswer( + first, + { + step: "first-result", + firstResult: { task: "add-medication" }, + }, + AT, + ); + expect(second.firstResult).toEqual({ + task: "add-medication", + target: null, + completedAt: null, + }); + }); + + it("records a skip without erasing an answer already given", () => { + const answered = applyOnboardingAnswer( + blank(), + { step: "visit", visit: "within-a-month" }, + AT, + ); + const skipped = applyOnboardingAnswer( + answered, + { step: "visit", status: "skipped" }, + AT, + ); + expect(skipped.steps.find((s) => s.id === "visit")?.status).toBe("skipped"); + expect(skipped.needs.visit).toBe("within-a-month"); + }); + + it("sets one unit without clearing the other", () => { + const both = applyOnboardingAnswer( + blank(), + { + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "metric" }, + }, + AT, + ); + const one = applyOnboardingAnswer( + both, + { step: "units", units: { unitPreference: "imperial" } }, + AT, + ); + expect(one.needs.units).toEqual({ + glucoseUnit: "mmol/L", + unitPreference: "imperial", + }); + }); +}); + +describe("isOnboardingSettled", () => { + const settled: OnboardingStateDto = { + steps: ONBOARDING_STEP_IDS.map((id) => ({ id, status: "done" as const })), + needs: emptyOnboardingNeeds(), + completedAt: "2026-09-10T08:00:00.000Z", + firstResult: { + task: "log-reading", + target: null, + completedAt: "2026-09-10T08:01:00.000Z", + }, + }; + + it("settles a record that never entered the flow", () => { + expect(isOnboardingSettled(null)).toBe(true); + }); + + it("settles a finished flow", () => { + expect(isOnboardingSettled(settled)).toBe(true); + }); + + it("does not settle while a step is pending", () => { + expect( + isOnboardingSettled({ + ...settled, + steps: settled.steps.map((s) => + s.id === "sources" ? { ...s, status: "pending" as const } : s, + ), + }), + ).toBe(false); + }); + + it("does not settle without the flow's own completion stamp", () => { + expect(isOnboardingSettled({ ...settled, completedAt: null })).toBe(false); + }); + + it("does not settle while the offered task has produced nothing", () => { + expect( + isOnboardingSettled({ + ...settled, + firstResult: { task: "log-reading", target: null, completedAt: null }, + }), + ).toBe(false); + }); +}); diff --git a/src/lib/onboarding/needs-apply.ts b/src/lib/onboarding/needs-apply.ts new file mode 100644 index 000000000..2c62ef99c --- /dev/null +++ b/src/lib/onboarding/needs-apply.ts @@ -0,0 +1,126 @@ +/** + * v1.39 (C1) — applying one answered step to the stored onboarding state. + * + * Pure, so the idempotence claim is checkable without a database: applying the + * same body twice returns a value equal to applying it once, which is what + * `PATCH /api/onboarding/answers` promises. The route reads the row, calls + * this, and writes the result back; nothing about "what an answer means" lives + * in the route. + * + * Kept beside the vocabulary rather than inside it so `needs.ts` keeps + * depending on nothing but the module registry — this file is the one place + * that knows both the stored shape and the request shape. + */ +import type { OnboardingAnswerInput } from "@/lib/validations/onboarding-needs"; + +import type { + OnboardingFirstResult, + OnboardingNeeds, + OnboardingStepId, + OnboardingStepState, + OnboardingStepStatus, +} from "./needs"; + +export interface OnboardingRecordState { + needs: OnboardingNeeds; + steps: OnboardingStepState[]; + firstResult: OnboardingFirstResult | null; +} + +function withStatus( + steps: readonly OnboardingStepState[], + id: OnboardingStepId, + status: OnboardingStepStatus, +): OnboardingStepState[] { + return steps.map((step) => (step.id === id ? { ...step, status } : step)); +} + +/** + * Apply one answer. + * + * A skip records the pass and leaves the answers exactly as they were: the + * spec's principle is that nothing is lost by a choice, and a person who + * answered Q2, went back and then skipped it should not have their areas + * erased by the skip. `now` is injected so the first-result stamp is the + * server's instant and the test can pin it. + */ +export function applyOnboardingAnswer( + state: OnboardingRecordState, + input: OnboardingAnswerInput, + now: Date, +): OnboardingRecordState { + if ("status" in input && input.status === "skipped") { + return { ...state, steps: withStatus(state.steps, input.step, "skipped") }; + } + + const steps = withStatus(state.steps, input.step, "done"); + + switch (input.step) { + case "who": + return { + ...state, + steps, + needs: { ...state.needs, recordTarget: input.recordTarget }, + }; + case "areas": + return { ...state, steps, needs: { ...state.needs, areas: input.areas } }; + case "medication": + return { + ...state, + steps, + needs: { ...state.needs, medication: input.medication }, + }; + case "sources": + return { + ...state, + steps, + needs: { ...state.needs, sources: input.sources }, + }; + case "visit": + return { ...state, steps, needs: { ...state.needs, visit: input.visit } }; + case "units": + return { + ...state, + steps, + needs: { + ...state.needs, + units: { + // An omitted field leaves the recorded answer alone; an explicit + // `null` clears it. The two cases are different on purpose — the + // screen may set one unit without the other. + glucoseUnit: + input.units.glucoseUnit === undefined + ? state.needs.units.glucoseUnit + : input.units.glucoseUnit, + unitPreference: + input.units.unitPreference === undefined + ? state.needs.units.unitPreference + : input.units.unitPreference, + }, + }, + }; + case "first-result": { + const already = + state.firstResult?.task === input.firstResult.task + ? state.firstResult.completedAt + : null; + return { + ...state, + steps, + firstResult: { + task: input.firstResult.task, + target: input.firstResult.target ?? null, + // Stamped once. A repeat of the same completed task keeps the + // instant it already had, which is what makes the write idempotent + // rather than merely repeatable. + completedAt: input.firstResult.completed + ? (already ?? now.toISOString()) + : already, + }, + }; + } + case "confirm": + case "done": + return { ...state, steps }; + } +} diff --git a/src/lib/onboarding/needs-store.ts b/src/lib/onboarding/needs-store.ts new file mode 100644 index 000000000..4532fab07 --- /dev/null +++ b/src/lib/onboarding/needs-store.ts @@ -0,0 +1,88 @@ +/** + * v1.39 (C1) — reading the needs-onboarding row, for the payload and for the + * three writes. + * + * One place that turns an `OnboardingRecord` row (or its absence) into the + * published DTO, so `GET /api/auth/me` and the write routes cannot disagree + * about what "nothing answered yet" looks like. A record with no row reads as + * empty answers and nine pending steps rather than as a missing object — a + * client then never branches on absence, and the account payload stays + * additive for every account that predates the flow. + */ +import type { PrismaClient } from "@/generated/prisma/client"; + +import { + defaultOnboardingSteps, + emptyOnboardingNeeds, + parseOnboardingFirstResult, + parseOnboardingNeeds, + parseOnboardingSteps, + type OnboardingStateDto, +} from "./needs"; +import type { OnboardingRecordState } from "./needs-apply"; + +/** The columns every reader below needs. */ +export const ONBOARDING_RECORD_SELECT = { + needsJson: true, + stepsJson: true, + firstResultJson: true, + modulesDerivedAt: true, + completedAt: true, +} as const; + +export interface OnboardingRecordRow { + needsJson: unknown; + stepsJson: unknown; + firstResultJson: unknown; + modulesDerivedAt: Date | null; + completedAt: Date | null; +} + +/** The answers / steps / first result, parsed fail-soft. */ +export function readOnboardingRecordState( + row: OnboardingRecordRow | null, +): OnboardingRecordState { + if (!row) { + return { + needs: emptyOnboardingNeeds(), + steps: defaultOnboardingSteps(), + firstResult: null, + }; + } + return { + needs: parseOnboardingNeeds(row.needsJson), + steps: parseOnboardingSteps(row.stepsJson), + firstResult: parseOnboardingFirstResult(row.firstResultJson), + }; +} + +/** The shape published on the account payload. */ +export function toOnboardingStateDto( + row: OnboardingRecordRow | null, +): OnboardingStateDto { + const state = readOnboardingRecordState(row); + return { + steps: state.steps, + needs: state.needs, + completedAt: row?.completedAt?.toISOString() ?? null, + firstResult: state.firstResult, + }; +} + +/** + * Load the published state for one record. + * + * Takes the delegate rather than the whole client, matching the section + * builders next door, so the route's global client and a worker's local one + * share the same read. + */ +export async function loadOnboardingState( + prisma: Pick, + recordId: string, +): Promise { + const row = await prisma.onboardingRecord.findUnique({ + where: { userId: recordId }, + select: ONBOARDING_RECORD_SELECT, + }); + return toOnboardingStateDto(row); +} diff --git a/src/lib/onboarding/needs.ts b/src/lib/onboarding/needs.ts new file mode 100644 index 000000000..20dd0c018 --- /dev/null +++ b/src/lib/onboarding/needs.ts @@ -0,0 +1,325 @@ +/** + * v1.39 (C1) — the needs-based onboarding's server state, as data. + * + * The design spec (`.planning/2026-09-09-onboarding-design-spec.md`) asks five + * questions, saves each answer as it is given, and derives the record's module + * map from the set of answers once. This file is the vocabulary and the + * fail-soft readers for the three JSON columns behind that: the answers, the + * ordered steps, and the one task the flow ended on. + * + * Pure and Prisma-free on purpose — the Zod schemas in + * `src/lib/validations/onboarding-needs.ts` build on it, the OpenAPI generator + * pulls it in without dragging the server graph along, and the browser reads + * the same step ids the server writes. + * + * ── Coexistence with the wizard of today ─────────────────────────────────── + * + * Nothing here retires anything. `User.onboardingStep`, `User.onboardingGoals` + * and the six goal slugs in `./goals.ts` keep their meaning and keep driving + * the four-step wizard and its one-time dashboard seed until C2 replaces that + * surface. The two states are disjoint by construction: the goal slugs decide + * TILE ORDER, these answers decide WHICH MODULES ARE ON, and neither reads the + * other. `User.onboardingCompletedAt` also stays what it was — the first-run + * redirect's gate — which is why the needs flow carries a completion stamp of + * its own rather than borrowing that column: "set up again" has to be able to + * re-ask the questions without pushing the person back through the first-run + * redirect. + */ +import type { + OnboardingAreaKey, + OnboardingModuleNeeds, +} from "@/lib/modules/registry"; +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; + +/** + * The ordered steps, with the stable ids the spec names. Order is the order + * they are asked in; the ids are the wire contract a client (web now, native + * later) matches on, and a client that meets an id it does not know is + * expected to skip it rather than refuse the list. + */ +export const ONBOARDING_STEP_IDS = [ + "who", + "areas", + "medication", + "sources", + "visit", + "units", + "confirm", + "first-result", + "done", +] as const; + +export type OnboardingStepId = (typeof ONBOARDING_STEP_IDS)[number]; + +export const ONBOARDING_STEP_STATUSES = ["pending", "done", "skipped"] as const; + +export type OnboardingStepStatus = (typeof ONBOARDING_STEP_STATUSES)[number]; + +/** + * The steps a person may pass without answering. `who` is the one required + * question in the spec; `confirm` and `done` are not questions at all, so + * "skipped" would not mean anything for them. + */ +export const ONBOARDING_SKIPPABLE_STEP_IDS = [ + "areas", + "medication", + "sources", + "visit", + "units", + "first-result", +] as const; + +export type OnboardingSkippableStepId = + (typeof ONBOARDING_SKIPPABLE_STEP_IDS)[number]; + +/** + * Q1 — whose record this is. The vocabularies below are pinned against the + * registry's derivation input with `satisfies`, so a value added here that the + * mapping cannot read fails to compile rather than falling through the + * derivation silently. + */ +export const ONBOARDING_RECORD_TARGETS = [ + "me", + "someone-else", + "both", +] as const satisfies readonly OnboardingModuleNeeds["recordTarget"][]; + +export type OnboardingRecordTarget = (typeof ONBOARDING_RECORD_TARGETS)[number]; + +/** Q3 — medication on a schedule. */ +export const ONBOARDING_MEDICATION_ANSWERS = [ + "yes", + "no", + "sometimes", +] as const satisfies readonly OnboardingModuleNeeds["medication"][]; + +export type OnboardingMedicationAnswer = + (typeof ONBOARDING_MEDICATION_ANSWERS)[number]; + +/** Q4 — where the readings come from today. */ +export const ONBOARDING_SOURCE_KEYS = [ + "manual", + "apple-health", + "withings", + "oura", + "whoop", + "polar", + "fitbit", + "strava", + "nightscout", + "file", +] as const; + +export type OnboardingSourceKey = (typeof ONBOARDING_SOURCE_KEYS)[number]; + +/** Q5 — a doctor's visit coming up. */ +export const ONBOARDING_VISIT_ANSWERS = [ + "within-a-month", + "later", + "no", +] as const satisfies readonly OnboardingModuleNeeds["visit"][]; + +export type OnboardingVisitAnswer = (typeof ONBOARDING_VISIT_ANSWERS)[number]; + +/** + * The one task the flow ends on. `target` names what the task is about — a + * source key for a connection, an area key for a reading — and is null for the + * medication task, which has nothing to disambiguate. + */ +export const ONBOARDING_FIRST_RESULT_TASKS = [ + "connect-source", + "add-medication", + "log-reading", +] as const; + +export type OnboardingFirstResultTask = + (typeof ONBOARDING_FIRST_RESULT_TASKS)[number]; + +/** + * Q6 — the unit answers, recorded as given. + * + * These are NOT the source of truth for how a value is displayed: + * `User.glucoseUnit` and `User.unitPreference` are, and the answers step + * writes them. What is kept here is what the flow was TOLD, so a restart can + * show the person the answer they already gave without re-deriving it from a + * display column somebody may have changed in Settings since. + */ +export interface OnboardingUnitAnswers { + glucoseUnit: "mg/dL" | "mmol/L" | null; + unitPreference: "metric" | "imperial" | null; +} + +/** The answers as given. Every field is null / empty until it is answered. */ +export interface OnboardingNeeds { + recordTarget: OnboardingRecordTarget | null; + areas: OnboardingAreaKey[]; + medication: OnboardingMedicationAnswer | null; + sources: OnboardingSourceKey[]; + visit: OnboardingVisitAnswer | null; + units: OnboardingUnitAnswers; +} + +export interface OnboardingStepState { + id: OnboardingStepId; + status: OnboardingStepStatus; +} + +export interface OnboardingFirstResult { + task: OnboardingFirstResultTask; + target: string | null; + /** ISO instant when the offered task produced its result, or null. */ + completedAt: string | null; +} + +/** + * What `GET /api/auth/me` publishes under `onboarding`, and what a client + * reads. Additive: an account that never entered the flow gets the empty + * answers and nine `pending` steps rather than a missing field, so a consumer + * never has to branch on absence. + */ +export interface OnboardingStateDto { + steps: OnboardingStepState[]; + needs: OnboardingNeeds; + completedAt: string | null; + firstResult: OnboardingFirstResult | null; +} + +export function emptyOnboardingNeeds(): OnboardingNeeds { + return { + recordTarget: null, + areas: [], + medication: null, + sources: [], + visit: null, + units: { glucoseUnit: null, unitPreference: null }, + }; +} + +/** Nine steps, all pending, in the spec's order. */ +export function defaultOnboardingSteps(): OnboardingStepState[] { + return ONBOARDING_STEP_IDS.map((id) => ({ id, status: "pending" as const })); +} + +function member( + values: readonly T[], + value: unknown, +): T | null { + return typeof value === "string" && + (values as readonly string[]).includes(value) + ? (value as T) + : null; +} + +function memberList( + values: readonly T[], + value: unknown, +): T[] { + if (!Array.isArray(value)) return []; + const out: T[] = []; + for (const entry of value) { + const parsed = member(values, entry); + if (parsed !== null && !out.includes(parsed)) out.push(parsed); + } + return out; +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** + * Read the stored answers, dropping anything that is not a known value. + * + * Fail-soft rather than throwing: a column written by an older release, or by + * a value this release retired, must not 500 the account payload that rides + * every app boot. What cannot be read reads as unanswered, which is the state + * the flow already knows how to handle. + */ +export function parseOnboardingNeeds(value: unknown): OnboardingNeeds { + const raw = asRecord(value); + const units = asRecord(raw.units); + return { + recordTarget: member(ONBOARDING_RECORD_TARGETS, raw.recordTarget), + areas: memberList(ONBOARDING_AREA_KEYS, raw.areas), + medication: member(ONBOARDING_MEDICATION_ANSWERS, raw.medication), + sources: memberList(ONBOARDING_SOURCE_KEYS, raw.sources), + visit: member(ONBOARDING_VISIT_ANSWERS, raw.visit), + units: { + glucoseUnit: member(["mg/dL", "mmol/L"] as const, units.glucoseUnit), + unitPreference: member( + ["metric", "imperial"] as const, + units.unitPreference, + ), + }, + }; +} + +/** + * Read the stored steps as the full ordered nine. + * + * The stored blob is treated as a status lookup, never as the order: the order + * is this release's `ONBOARDING_STEP_IDS`, so a step added to the flow appears + * as `pending` for an account mid-way through rather than vanishing, and a + * step removed from the flow stops being published without a migration. + */ +export function parseOnboardingSteps(value: unknown): OnboardingStepState[] { + const stored = new Map(); + if (Array.isArray(value)) { + for (const entry of value) { + const row = asRecord(entry); + const id = member(ONBOARDING_STEP_IDS, row.id); + const status = member(ONBOARDING_STEP_STATUSES, row.status); + if (id && status) stored.set(id, status); + } + } + return ONBOARDING_STEP_IDS.map((id) => ({ + id, + status: stored.get(id) ?? "pending", + })); +} + +export function parseOnboardingFirstResult( + value: unknown, +): OnboardingFirstResult | null { + const raw = asRecord(value); + const task = member(ONBOARDING_FIRST_RESULT_TASKS, raw.task); + if (!task) return null; + return { + task, + target: typeof raw.target === "string" ? raw.target.slice(0, 64) : null, + completedAt: + typeof raw.completedAt === "string" && raw.completedAt.length > 0 + ? raw.completedAt + : null, + }; +} + +/** True once every step has been answered or deliberately passed. */ +export function everyOnboardingStepSettled( + steps: readonly OnboardingStepState[], +): boolean { + return steps.every((step) => step.status !== "pending"); +} + +/** + * Has the person finished setting up? + * + * Three conditions, and each of them is a way the flow can be left half-done: + * a step still waiting for an answer, a set of answers never confirmed, and a + * first-result task that was offered and never produced. The last one is the + * point of the whole flow — it ends on one completed task — so an offer with + * no result is not a finished setup. + * + * `null` (a record that never entered the flow) settles: there is no + * unfinished flow to keep anything open for. + */ +export function isOnboardingSettled( + state: OnboardingStateDto | null | undefined, +): boolean { + if (!state) return true; + if (!everyOnboardingStepSettled(state.steps)) return false; + if (state.completedAt === null) return false; + return state.firstResult === null || state.firstResult.completedAt !== null; +} diff --git a/src/lib/validations/__tests__/onboarding-needs.test.ts b/src/lib/validations/__tests__/onboarding-needs.test.ts new file mode 100644 index 000000000..76d8a9b74 --- /dev/null +++ b/src/lib/validations/__tests__/onboarding-needs.test.ts @@ -0,0 +1,105 @@ +/** + * v1.39 (C1) — what `PATCH /api/onboarding/answers` will and will not accept. + * + * The union has an answer arm per step and a skip arm for six of the nine, and + * the split is a decision rather than a shape: `who` is the one required + * question, and `confirm` / `done` are acknowledgements. A skip arm added to + * one of those three would let a client record a pass through a screen that + * does not offer one, so the arms are checked against + * `ONBOARDING_SKIPPABLE_STEP_IDS` rather than against a list written twice. + * + * The other property here is that a skip can never carry an answer. That is + * what makes "skipped, and my areas are …" unrepresentable rather than merely + * discouraged, and it is why the route can trust the parsed body without + * re-checking the combination. + */ +import { describe, expect, it } from "vitest"; + +import { + ONBOARDING_SKIPPABLE_STEP_IDS, + ONBOARDING_STEP_IDS, +} from "@/lib/onboarding/needs"; +import { onboardingAnswerSchema } from "../onboarding-needs"; + +const SKIPPABLE: readonly string[] = ONBOARDING_SKIPPABLE_STEP_IDS; + +describe("onboardingAnswerSchema", () => { + it("accepts a skip for exactly the steps that may be passed", () => { + for (const step of ONBOARDING_STEP_IDS) { + const parsed = onboardingAnswerSchema.safeParse({ + step, + status: "skipped", + }); + expect( + parsed.success, + `\`${step}\` ${SKIPPABLE.includes(step) ? "should" : "should not"} be skippable`, + ).toBe(SKIPPABLE.includes(step)); + } + }); + + it("refuses a skip that carries an answer", () => { + expect( + onboardingAnswerSchema.safeParse({ + step: "areas", + status: "skipped", + areas: ["sleep"], + }).success, + ).toBe(false); + }); + + it("refuses an answer for the wrong step", () => { + expect( + onboardingAnswerSchema.safeParse({ step: "areas", medication: "yes" }) + .success, + ).toBe(false); + }); + + it("refuses a value outside the closed set", () => { + expect( + onboardingAnswerSchema.safeParse({ step: "visit", visit: "someday" }) + .success, + ).toBe(false); + expect( + onboardingAnswerSchema.safeParse({ step: "areas", areas: ["astrology"] }) + .success, + ).toBe(false); + }); + + it("refuses an unknown key alongside a valid answer", () => { + expect( + onboardingAnswerSchema.safeParse({ + step: "who", + recordTarget: "me", + userId: "someone-else", + }).success, + ).toBe(false); + }); + + it("accepts each answered step with its own payload", () => { + const bodies: unknown[] = [ + { step: "who", recordTarget: "both" }, + { step: "areas", areas: ["glucose", "cycle"] }, + { step: "medication", medication: "sometimes" }, + { step: "sources", sources: ["nightscout", "manual"] }, + { step: "visit", visit: "later" }, + { step: "units", units: { glucoseUnit: "mmol/L" } }, + { step: "confirm" }, + { + step: "first-result", + firstResult: { + task: "log-reading", + target: "glucose", + completed: true, + }, + }, + { step: "done" }, + ]; + expect(bodies.length).toBe(ONBOARDING_STEP_IDS.length); + for (const body of bodies) { + expect( + onboardingAnswerSchema.safeParse(body).success, + JSON.stringify(body), + ).toBe(true); + } + }); +}); diff --git a/src/lib/validations/onboarding-needs.ts b/src/lib/validations/onboarding-needs.ts new file mode 100644 index 000000000..043920fac --- /dev/null +++ b/src/lib/validations/onboarding-needs.ts @@ -0,0 +1,119 @@ +/** + * v1.39 (C1) — request schemas for the three needs-onboarding writes. + * + * They live outside the route files for the reason `./onboarding.ts` gives: a + * route module may only export handlers plus the Next.js route config, so the + * OpenAPI registry cannot import a schema declared inside one. Keeping the + * shapes here makes the published contract and the runtime parser the same + * object. + * + * Prisma-free by construction — everything below reads its vocabulary from + * `@/lib/onboarding/needs` and `@/lib/modules/registry`, both pure data. + */ +import { z } from "zod/v4"; + +import { ONBOARDING_AREA_KEYS } from "@/lib/modules/registry"; +import { + ONBOARDING_FIRST_RESULT_TASKS, + ONBOARDING_MEDICATION_ANSWERS, + ONBOARDING_RECORD_TARGETS, + ONBOARDING_SOURCE_KEYS, + ONBOARDING_VISIT_ANSWERS, +} from "@/lib/onboarding/needs"; + +/** + * One answered step. `status` is optional and only ever `"done"` on this arm — + * a skip is the separate arm below, so a body can never claim to skip a step + * and carry an answer for it at the same time. + */ +function answered( + id: Id, + shape: Shape, +) { + return z + .object({ + step: z.literal(id), + status: z.literal("done").optional(), + ...shape, + }) + .strict(); +} + +/** One deliberately passed step. No answer may ride along. */ +function skipped(id: Id) { + return z + .object({ step: z.literal(id), status: z.literal("skipped") }) + .strict(); +} + +/** + * Q6 — the unit answers. Both fields are optional and nullable: the screen + * only appears when glucose or weight is among the areas, and it may set one + * without the other. The route writes the record's real display columns from + * these and records the answer beside them. + */ +export const onboardingUnitsSchema = z + .object({ + glucoseUnit: z.enum(["mg/dL", "mmol/L"]).nullable().optional(), + unitPreference: z.enum(["metric", "imperial"]).nullable().optional(), + }) + .strict(); + +/** + * The task the flow ended on. `completed` is what turns the offer into a + * result — the server stamps the instant rather than taking one from the + * client, so a replay cannot backdate it. + */ +export const onboardingFirstResultSchema = z + .object({ + task: z.enum(ONBOARDING_FIRST_RESULT_TASKS), + target: z.string().trim().min(1).max(64).nullable().optional(), + completed: z.boolean().optional(), + }) + .strict(); + +/** + * `PATCH /api/onboarding/answers` — one step at a time. + * + * A flat union rather than a discriminated one: each step id appears twice + * (answered and skipped) and `z.discriminatedUnion` needs one option per + * literal. The three steps that are not questions — `who` is required, and + * `confirm` / `done` are acknowledgements — have no skip arm, so a client + * cannot record a pass through a screen that does not offer one. + */ +export const onboardingAnswerSchema = z.union([ + answered("who", { recordTarget: z.enum(ONBOARDING_RECORD_TARGETS) }), + answered("areas", { + areas: z + .array(z.enum(ONBOARDING_AREA_KEYS)) + .max(ONBOARDING_AREA_KEYS.length), + }), + answered("medication", { + medication: z.enum(ONBOARDING_MEDICATION_ANSWERS), + }), + answered("sources", { + sources: z + .array(z.enum(ONBOARDING_SOURCE_KEYS)) + .max(ONBOARDING_SOURCE_KEYS.length), + }), + answered("visit", { visit: z.enum(ONBOARDING_VISIT_ANSWERS) }), + answered("units", { units: onboardingUnitsSchema }), + answered("confirm", {}), + answered("first-result", { firstResult: onboardingFirstResultSchema }), + answered("done", {}), + skipped("areas"), + skipped("medication"), + skipped("sources"), + skipped("visit"), + skipped("units"), + skipped("first-result"), +]); + +export type OnboardingAnswerInput = z.infer; + +/** + * `POST /api/onboarding/restart` — no input beyond the session. Strict and + * empty rather than unvalidated, so a client that starts sending a field gets + * a 422 that names it instead of having it silently ignored. + */ +export const onboardingRestartSchema = z.object({}).strict();