feat(onboarding): store what a record needs, and map it to modules

The setup questionnaire the design spec asks for needs somewhere to put its
answers. `OnboardingRecord` is that place: one row per RECORD rather than per
person, so a guardian running a managed profile's setup writes the child's row
and not their own, and the three JSON columns hold the answers, the nine
ordered steps with their status, and the one task the flow ends on.

Nothing here is encrypted, and the schema comment says why: the columns hold
closed enum vocabularies, not free text, which is the same class of value as
the module map and the goal slugs sitting in plaintext beside them.

The needs to modules map lives next to the module keys rather than in the flow,
because a module renamed or retired has to break the map at compile time rather
than leave a chip that quietly changes nothing. The derivation is total — every
directly-owned key gets an explicit value — and the merge beside it is
one-directional: what the answers name is switched on, what they do not name is
switched off only where the record does not already carry a deliberate on. The
two delegated keys are absent by construction: `cycle` is reported separately
because its real column is on the cycle profile, and the Coach is not offered by
this flow at all.

Readers are fail-soft. They sit on the payload that rides every app boot, and a
value written by a release that named an area this one retired must not take the
shell down with it.

Nothing is retired: the four-step wizard, its goal slugs and the one-time tile
seed keep working and keep their meaning until the new surface replaces them.
The two states are disjoint — the slugs decide tile order, these answers decide
which modules are on, and neither reads the other.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 11:03:24 +02:00
1 parent 036720bcfb
commit cc98c56172
10 files changed
+1514

No files matched your search

@@ -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;
+66
View File
@@ -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")
}
@@ -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> = {},
): 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");
});
});
+186
View File
@@ -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<OnboardingAreaKey, readonly ModuleKey[]>
> = 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<ModuleKey, ModuleDelegation>;
/** 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<OwnedModuleKey, boolean>;
/**
* 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<ModuleKey>(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<OwnedModuleKey, boolean>;
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<Record<string, boolean>>,
derived: Readonly<Record<OwnedModuleKey, boolean>>,
): Record<string, boolean> {
const merged: Record<string, boolean> = {};
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;
}
+262
View File
@@ -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);
});
});
+126
View File
@@ -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 };
}
}
+88
View File
@@ -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<PrismaClient, "onboardingRecord">,
recordId: string,
): Promise<OnboardingStateDto> {
const row = await prisma.onboardingRecord.findUnique({
where: { userId: recordId },
select: ONBOARDING_RECORD_SELECT,
});
return toOnboardingStateDto(row);
}
+325
View File
@@ -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<T extends string>(
values: readonly T[],
value: unknown,
): T | null {
return typeof value === "string" &&
(values as readonly string[]).includes(value)
? (value as T)
: null;
}
function memberList<T extends string>(
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/**
* 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<string, OnboardingStepStatus>();
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;
}
@@ -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);
}
});
});
+119
View File
@@ -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 extends string, Shape extends z.ZodRawShape>(
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 extends string>(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<typeof onboardingAnswerSchema>;
/**
* `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();