Merge the needs-based onboarding's server state and the derivation that never hides data

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 12:23:34 +02:00
commit 3017e24bb1
41 files changed
+5272 -83

No files matched your search

+693 -5
View File
@@ -16196,6 +16196,125 @@ paths:
"401": *a1
"422": *a4
"429": *a5
/api/onboarding/answers:
patch:
tags:
- Onboarding
summary: Save one answer of the needs-based setup flow
description: >-
Persists one step of the setup questionnaire the moment it is answered, so leaving and returning resumes at the
same step. The body names the step and carries either its answer or `status: "skipped"`; the two can never ride
together.
Idempotent. The stored state is computed by a pure function of the row and the body, so sending the same step
twice writes the same value and the second write changes nothing a reader can see — including the first-result
completion instant, which is kept from the existing row rather than re-taken. There is no ordering contract:
steps may be answered, re-answered and passed in any order, which is what makes a back button work.
The `units` step is the one answer with a consequence outside this state: it writes the record's `glucoseUnit`
and `unitPreference`, which every display surface already reads, and records the answer beside them.
Cookie or wildcard Bearer, and the CALLER's own record only: a request made while the session is acting on
somebody else's record is refused with 403 `sharing.not_permitted` at every grant level, so a delegate can never
write another record's setup. Rate-limited to 120 writes per 10 minutes per account.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardingAnswerRequest"
responses:
"200":
description: The full setup state after the write.
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardingStateEnvelope"
"401": *a1
"413":
description: Body exceeds 16 KiB.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"415":
description: "`Content-Type` is not `application/json`."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: The body did not validate. `meta.errorCode` = `onboarding.answers.invalid`, with every issue on the wire.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429":
description: More than 120 answer writes in 10 minutes. `meta.errorCode` = `onboarding.answers.rateLimited`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/onboarding/restart:
post:
tags:
- Onboarding
summary: Ask the setup questions again
description: >-
"Set up again", from Settings. Puts the nine steps back to `pending`, clears the flow's own completion stamp,
and clears the derivation marker so the next `POST /api/onboarding/complete` may derive a module map again.
It writes no module state at all: the ordering-versus-removal decision says a re-run never turns off a module
somebody turned on by hand, and the protection against the second derivation lives in the merge rather than
here. It also keeps the answers, as the prefill for the re-run, keeps a first result that really happened, and
leaves the account payload's `onboardingCompletedAt` alone so nobody is pushed back through the first-run
redirect.
Cookie or wildcard Bearer, own record only — same refusal under a switch as the answers route. Rate-limited to
10 restarts per 10 minutes per account.
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardingRestartRequest"
responses:
"200":
description: The reset setup state.
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardingStateEnvelope"
"401": *a1
"413":
description: Body exceeds 4 KiB.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"415":
description: "`Content-Type` is not `application/json`."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: The body carried a field. `meta.errorCode` = `onboarding.restart.invalid`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429":
description: More than 10 restarts in 10 minutes. `meta.errorCode` = `onboarding.restart.rateLimited`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/onboarding/step:
post:
tags:
@@ -16279,16 +16398,35 @@ paths:
post:
tags:
- Onboarding
summary: Stamp onboarding as complete (legacy single-shot path)
summary: Stamp onboarding as complete, and derive the module map
description: >-
Marks onboarding finished and saves whatever profile fields came with it, then clears the proxy-readable pending
cookie so the next navigation stops redirecting to `/onboarding`.
v1.39 — this is also the needs-based flow's confirm endpoint, and the two halves live side by side. For a record
that FINISHED the questions — every one of them answered or deliberately passed — it derives that record's
module map from the answers and applies it, ONCE: the derivation marker is what stops a second confirm
re-applying the questionnaire over decisions taken in Settings since, and `POST /api/onboarding/restart` clears
the marker when the person asks for the questions again. The merge is one-directional — a module the answers
name is switched on, a module they do not name is switched off only where the record does not already carry an
explicit on. The `cycle` key is not written here at all: it delegates to the cycle profile, which this route
sets to `true` when the cycle area was chosen and never to `false`. A half-answered flow derives nothing and is
not stamped as complete: the answers become a module map, and a flow somebody abandoned after the first screen
would have every remaining question read as its conservative default. The same gate is what keeps the LEGACY
wizard's completion out of the derivation, since that wizard never puts these questions.
The response then carries `onboarding` beside `completed`; a caller that never entered the needs flow gets the
fixed acknowledgement it always did.
The older sibling of `POST /api/onboarding/step`. It is unconditional in a way the step route is not: it stamps
the completion whatever the stored step is, it re-stamps on every call rather than refusing a second one, and it
writes no audit row and enforces no rate limit of its own. It also does NOT seed the dashboard from the goal
selection — that only happens on the step route's completing call.
enforces no rate limit of its own. Its legacy half writes no audit row; the needs half writes one
(`user.modules.update`) when it derives, because that is the same column the dedicated modules route audits. It
also does NOT seed the dashboard from the goal selection — that only happens on the step route's completing
call.
Calling this mid-wizard makes every remaining `POST /api/onboarding/step` answer 409
@@ -16304,8 +16442,9 @@ paths:
$ref: "#/components/schemas/OnboardingCompleteRequest"
responses:
"200":
description: "Onboarding stamped complete. The body is the fixed `{ completed: true }` acknowledgement — it does not
echo what was saved."
description: Onboarding stamped complete. `completed` is the fixed acknowledgement — it does not echo the profile fields
that were saved. `onboarding` is present only for a record that entered the needs-based flow, and carries
the state after the derivation.
content:
application/json:
schema:
@@ -27588,6 +27727,282 @@ components:
minLength: 1
required:
- ids
OnboardingAnswerRequest:
anyOf:
- type: object
properties:
step:
type: string
const: who
status:
type: string
const: done
recordTarget:
type: string
enum:
- me
- someone-else
- both
required:
- step
- recordTarget
additionalProperties: false
- type: object
properties:
step:
type: string
const: areas
status:
type: string
const: done
areas:
maxItems: 9
type: array
items:
type: string
enum:
- blood-pressure
- weight-body
- glucose
- sleep
- mood
- cycle
- activity
- labs
- illness
required:
- step
- areas
additionalProperties: false
- type: object
properties:
step:
type: string
const: medication
status:
type: string
const: done
medication:
type: string
enum:
- yes
- no
- sometimes
required:
- step
- medication
additionalProperties: false
- type: object
properties:
step:
type: string
const: sources
status:
type: string
const: done
sources:
maxItems: 10
type: array
items:
type: string
enum:
- manual
- apple-health
- withings
- oura
- whoop
- polar
- fitbit
- strava
- nightscout
- file
required:
- step
- sources
additionalProperties: false
- type: object
properties:
step:
type: string
const: visit
status:
type: string
const: done
visit:
type: string
enum:
- within-a-month
- later
- no
required:
- step
- visit
additionalProperties: false
- type: object
properties:
step:
type: string
const: units
status:
type: string
const: done
units:
type: object
properties:
glucoseUnit:
anyOf:
- type: string
enum:
- mg/dL
- mmol/L
- type: "null"
unitPreference:
anyOf:
- type: string
enum:
- metric
- imperial
- type: "null"
additionalProperties: false
required:
- step
- units
additionalProperties: false
- type: object
properties:
step:
type: string
const: confirm
status:
type: string
const: done
required:
- step
additionalProperties: false
- type: object
properties:
step:
type: string
const: first-result
status:
type: string
const: done
firstResult:
type: object
properties:
task:
type: string
enum:
- connect-source
- add-medication
- log-reading
target:
anyOf:
- type: string
minLength: 1
maxLength: 64
- type: "null"
completed:
type: boolean
required:
- task
additionalProperties: false
required:
- step
- firstResult
additionalProperties: false
- type: object
properties:
step:
type: string
const: done
status:
type: string
const: done
required:
- step
additionalProperties: false
- type: object
properties:
step:
type: string
const: areas
status:
type: string
const: skipped
required:
- step
- status
additionalProperties: false
- type: object
properties:
step:
type: string
const: medication
status:
type: string
const: skipped
required:
- step
- status
additionalProperties: false
- type: object
properties:
step:
type: string
const: sources
status:
type: string
const: skipped
required:
- step
- status
additionalProperties: false
- type: object
properties:
step:
type: string
const: visit
status:
type: string
const: skipped
required:
- step
- status
additionalProperties: false
- type: object
properties:
step:
type: string
const: units
status:
type: string
const: skipped
required:
- step
- status
additionalProperties: false
- type: object
properties:
step:
type: string
const: first-result
status:
type: string
const: skipped
required:
- step
- status
additionalProperties: false
description: "One step, answered or deliberately passed. The union has an arm per step id plus a skip arm for the six
steps that may be passed — `who` is the one required question, and `confirm` / `done` are acknowledgements
rather than questions, so none of the three has a skip arm. Every arm is strict: an unknown key is a 422, and a
skip may never carry an answer."
OnboardingRestartRequest:
type: object
properties: {}
additionalProperties: false
description: No input beyond the session. Send `{}`.
OnboardingStepRequest:
type: object
properties:
@@ -34729,6 +35144,62 @@ components:
- configChangedAt
- computedAt
additionalProperties: {}
onboardingRecord:
default: null
anyOf:
- type: object
properties:
id:
type: string
minLength: 1
needs:
default: {}
type: object
propertyNames:
type: string
additionalProperties: {}
steps:
default: []
type: array
items:
type: object
propertyNames:
type: string
additionalProperties: {}
firstResult:
default: null
anyOf:
- type: object
propertyNames:
type: string
additionalProperties: {}
- type: "null"
modulesDerivedAt:
default: null
anyOf:
- type: string
minLength: 1
- type: "null"
completedAt:
default: null
anyOf:
- type: string
minLength: 1
- type: "null"
createdAt:
type: string
minLength: 1
updatedAt:
type: string
minLength: 1
required:
- needs
- steps
- firstResult
- modulesDerivedAt
- completedAt
additionalProperties: {}
- type: "null"
practitioners:
default: []
type: array
@@ -36067,6 +36538,7 @@ components:
- correlationPatterns
- intradayProfiles
- healthScoreRecords
- onboardingRecord
- practitioners
- encounters
- encounterDocumentLinks
@@ -62793,6 +63265,217 @@ components:
- data
- error
additionalProperties: false
OnboardingStateEnvelope:
type: object
properties:
data:
type: object
properties:
onboarding:
$ref: "#/components/schemas/OnboardingState"
required:
- onboarding
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
OnboardingState:
type: object
properties:
steps:
type: array
items:
$ref: "#/components/schemas/OnboardingStepState"
needs:
$ref: "#/components/schemas/OnboardingNeeds"
completedAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: "When the needs flow was completed. Distinct from the account payload's `onboardingCompletedAt`, which
stays the first-run redirect's gate: `POST /api/onboarding/restart` clears this one and leaves that one
alone."
firstResult:
anyOf:
- $ref: "#/components/schemas/OnboardingFirstResult"
- type: "null"
required:
- steps
- needs
- completedAt
- firstResult
additionalProperties: false
description: The needs-based setup flow for one record. Always present on the account payload — a record that never
entered the flow reads as empty answers with nine `pending` steps, so a client branches on the step statuses
rather than on the field's existence. On the account payload it describes the ACTIVE record, so a client under
an account switch reads the same record its counts and module map describe; under a scoped grant the answers
come back empty.
OnboardingStepState:
type: object
properties:
id:
type: string
enum:
- who
- areas
- medication
- sources
- visit
- units
- confirm
- first-result
- done
status:
type: string
enum:
- pending
- done
- skipped
required:
- id
- status
additionalProperties: false
description: >-
One step of the setup flow. The ids are stable; a client that meets one it does not know SKIPS it rather than
refusing the list, so the flow may grow a screen without a client release.
`units` carries one resolution the others do not: a record whose account already holds BOTH unit preferences
reads as `done` whether or not the question was ever put, which is what stops the flow re-asking a value the
account holds.
OnboardingNeeds:
type: object
properties:
recordTarget:
anyOf:
- type: string
enum:
- me
- someone-else
- both
- type: "null"
description: Q1 — whose record this is. Null until answered.
areas:
type: array
items:
type: string
enum:
- blood-pressure
- weight-body
- glucose
- sleep
- mood
- cycle
- activity
- labs
- illness
description: Q2 — the areas to keep an eye on. Each one maps to at least one module through `ONBOARDING_AREA_MODULES`;
the map is the server's, and a client must not re-derive it.
medication:
anyOf:
- type: string
enum:
- yes
- no
- sometimes
- type: "null"
description: Q3 — medication on a schedule.
sources:
type: array
items:
type: string
enum:
- manual
- apple-health
- withings
- oura
- whoop
- polar
- fitbit
- strava
- nightscout
- file
description: Q4 — where the readings come from today.
visit:
anyOf:
- type: string
enum:
- within-a-month
- later
- no
- type: "null"
description: Q5 — a doctor's visit coming up.
units:
type: object
properties:
glucoseUnit:
anyOf:
- type: string
enum:
- mg/dL
- mmol/L
- type: "null"
unitPreference:
anyOf:
- type: string
enum:
- metric
- imperial
- type: "null"
required:
- glucoseUnit
- unitPreference
additionalProperties: false
description: "Q6 — the unit answers as given. NOT the source of truth for display: `glucoseUnit` and `unitPreference` on
the account payload are, and the answers step writes them. This records what the flow was told."
required:
- recordTarget
- areas
- medication
- sources
- visit
- units
additionalProperties: false
description: The setup answers as given. Every field is null or empty until its question is answered.
OnboardingFirstResult:
type: object
properties:
task:
type: string
enum:
- connect-source
- add-medication
- log-reading
target:
anyOf:
- type: string
- type: "null"
description: What the task is about — a source key for a connection, an area key for a reading. Null for the medication
task.
completedAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
description: When the offered task produced its result, or null.
required:
- task
- target
- completedAt
additionalProperties: false
description: The one task the flow offered at the end, and whether it produced its result.
OnboardingStepEnvelope:
type: object
properties:
@@ -62838,6 +63521,8 @@ components:
completed:
type: boolean
const: true
onboarding:
$ref: "#/components/schemas/OnboardingState"
required:
- completed
additionalProperties: false
@@ -66488,6 +67173,8 @@ components:
required:
- medication
additionalProperties: {}
onboarding:
$ref: "#/components/schemas/OnboardingState"
required:
- id
- username
@@ -66495,6 +67182,7 @@ components:
- moduleAccess
- recordSession
- notificationPrefs
- onboarding
additionalProperties: {}
description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do.
Additional properties are the preference fields this spec does not yet enumerate. Under an active switch the