diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c5db66a..89c749534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ ## [Unreleased] +## [1.18.0] — 2026-06-16 — turn off what you don't track, and a settings menu that finally reads as one app + +This release makes the app yours to shape. Every optional area — cycle, mood, sleep, glucose, workouts, recovery, labs, achievements, the coach, AI insights and the doctor-report — can be turned off, and when it is, it disappears everywhere at once: out of the navigation, off the dashboard, gone from insights, the coach, your reminders, achievements and the exported report. Your data is kept; turn the area back on and it returns. The settings menu was redesigned alongside it, so notification channels, integrations, sources and per-area pages each sit where you'd look for them instead of spread across overlapping hubs. The core vitals — weight, blood pressure, pulse and medications — are always on. No breaking changes. + +### Added + +- **Turn modules on and off.** A "What you track" settings hub lets you switch off any area you don't use. The switch is honoured by one server-side gate, so a disabled area is hidden from the navigation and the dashboard and is also refused at its API — the surface and the route vanish together, with no window where one leaks without the other. Re-enabling restores everything; nothing is deleted. +- **Operator-level module availability.** A self-hoster can make an area unavailable for the whole instance from the admin settings; an operator "off" wins over any personal preference. Both layers project onto the same module map the web app and the companion app already read, so neither client needs special handling. + +### Changed + +- **The settings menu reads as one app.** Notification channels moved under Integrations alongside the connection and source panels; the duplicated "Reminders" hub collapsed into Notifications; mood, medications and the health-record export each became a clear top-level entry; and every section now carries one consistent heading. Old deep links (`/settings/reminders`, `/settings/sources`, the standalone coach page) redirect to their new homes. +- **One add-entry pattern across every feature page.** The cycle, labs, mood, medication and preventive-care pages now share the same header and primary "add" affordance, so a page built later doesn't read differently from one built earlier. + +### Operator note + +- Migrations 0167 (per-user module preferences) and 0168 (operator-level module availability) are additive — a single nullable JSON column each, no backfill. Both default to "all on": an area is disabled only by an explicit choice. No deploy-time action is required. + ## [1.17.1] — 2026-06-15 — preventive care, lab results, and more of the data you already have This release closes the loop from tracking to acting. Preventive-care reminders tell you when to measure or check what, structured lab results give your bloodwork a home, and a new recovery view surfaces signals that were already being collected but never shown. Sleep timelines now read in real clock times, marking a dose on one device clears its reminder on the others within seconds, and self-hosters gain a generic webhook channel, email, one-tap Web Push setup and a proper notifications guide. Every new number is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app. No breaking changes. diff --git a/CLAUDE.md b/CLAUDE.md index 5cea17215..b50def4aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,3 +193,8 @@ The native SwiftUI client lives in a separate repository and rides public beta v | iOS coordination notes | `.planning/ios-coord/` | | Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) | | Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) | + +## Agent context hygiene (ECONNRESET / oversized-request prevention) +- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET` on large turns. Regenerate via `pnpm prisma generate` if needed; never open it. (Enforced via `.claude/settings.local.json` Read-deny.) +- Avoid bulk-reading other large artifacts: `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json`, `docs/api/openapi.yaml` — grep narrowly instead of full reads. +- For very large multi-agent actions: cap concurrent sub-agents (many simultaneous streaming requests + big context is the worst combo for connection resets). When on a VPN (Tailscale MTU 1280 / iCloud Private Relay), large requests can hit MTU black-holes — disable the tunnel for heavy sessions. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0a6360a77..af8ba7573 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.17.1 + version: 1.18.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1918,6 +1918,14 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 + "403": &a5 + description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = + `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a + valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/sleep/rhythm: @@ -1939,6 +1947,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 + "403": *a5 "422": *a3 "429": *a4 /api/workouts: @@ -3081,6 +3090,50 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/modules: + get: + tags: + - Auth + summary: Read the resolved per-user module enable/disable map + description: "Returns the fully-resolved `{ : boolean }` map for every toggleable module. `cycle` reflects + the cycle gate, `coach` reflects the resolved per-user opt-out + operator master flag, and the rest read the + per-user disabled-allowlist (`modulePreferencesJson`). Default-on: a fresh account reads every module enabled. + iOS/web read this to hide a whole module surface end-to-end." + responses: + "200": + description: Resolved module map. + content: + application/json: + schema: + $ref: "#/components/schemas/GetModulesResponse" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Auth + summary: Update per-user module enable/disable preferences + description: Merges the supplied keys into the persisted disabled-allowlist (`modulePreferencesJson`) field-by-field — a + PATCH touching only one module leaves the siblings intact. Refuses to disable a core module (`weight`, + `bloodPressure`, `pulse`, `medications`) or any unknown key with a 422. `userId` is never accepted from the + body. Always returns the fully-resolved next-state map so clients can hard-set their optimistic update. + Rate-limit 60/min per user. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModulePrefsPatchRequest" + responses: + "200": + description: Resolved next-state module map echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchModulesResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/me/source-priority: get: tags: @@ -3883,6 +3936,224 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/chat: + get: + tags: + - Insights + summary: List the caller's Coach conversations + description: v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent + activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. + `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at + the end). Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; + the owner is narrowed from the session. + parameters: + - name: cursor + in: query + required: false + schema: + type: string + description: Id of the last conversation on the previous page. Omit for the first page. + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + description: Page size. Defaults to 20, capped at 50. + responses: + "200": + description: A page of the caller's conversations. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachConversationsPageResponse" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Insights + summary: Send a Coach turn (streaming reply) + description: "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is + `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk + of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{ + type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a + provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame + types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and + rate-limited. Auth via cookie or Bearer." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + conversationId: + type: string + minLength: 1 + maxLength: 64 + message: + type: string + minLength: 1 + maxLength: 4000 + prefill: + type: string + maxLength: 2000 + locale: + type: string + enum: + - en + - de + scope: + type: object + properties: + sources: + maxItems: 40 + type: array + items: + type: string + enum: + - bp + - weight + - pulse + - mood + - compliance + - hrv + - sleep + - resting_hr + - steps + - active_energy + - flights + - distance + - vo2_max + - body_temp + - walking_hr + - respiratory_rate + - spo2 + - pulse_wave_velocity + - vascular_age + - body_fat + - fat_mass + - fat_free_mass + - muscle_mass + - lean_body_mass + - bone_mass + - total_body_water + - bmi + - visceral_fat + - glucose + - walking_steadiness + - walking_asymmetry + - walking_double_support + - walking_step_length + - walking_speed + - audio_env + - audio_headphone + - audio_event + - daylight + - skin_temp + - workouts + window: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + guidedQuestion: + type: string + minLength: 1 + maxLength: 500 + required: + - message + responses: + "200": + description: Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames. + content: + text/event-stream: + schema: + type: string + description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." + "401": *a1 + "403": + description: "Coach surface disabled (`errorCode: assistant.disabled.coach`) or AI consent required (`errorCode: + consent.ai.required`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: Request body exceeds the 64 KB cap. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/insights/chat/{id}: + get: + tags: + - Insights + summary: Read one Coach conversation with all messages + description: v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus + the rolling `summary` when one is on file. The client renders the bodies directly; no decryption key is + involved. A foreign / unknown id maps to 404 (never 403) so the existence channel does not leak across accounts. + Coach-gated. Auth via cookie or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Conversation id. + responses: + "200": + description: The conversation with all decrypted messages. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachConversationDetailResponse" + "401": *a1 + "404": + description: Conversation not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Insights + summary: Delete one Coach conversation + description: v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 (never + 403). Coach-gated. Auth via cookie or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Conversation id. + responses: + "200": + description: The conversation was deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachConversationDeleted" + "401": *a1 + "404": + description: Conversation not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/insights/layout: get: tags: @@ -4892,7 +5163,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a5 + "404": &a6 description: Not found / not owned by the caller. content: application/json: @@ -4927,7 +5198,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a5 + "404": *a6 "422": *a3 "429": *a4 /api/mood/tags/{key}/hidden: @@ -4968,7 +5239,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a5 + "404": *a6 "422": *a3 "429": *a4 /api/mood/tags/groups: @@ -5043,7 +5314,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a5 + "404": *a6 "422": *a3 "429": *a4 delete: @@ -5074,7 +5345,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a5 + "404": *a6 "422": *a3 "429": *a4 /api/mood/tags/layout: @@ -5327,7 +5598,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "404": &a6 + "404": &a7 description: Measurement reminder not found / not owned. content: application/json: @@ -5361,7 +5632,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "404": *a6 + "404": *a7 "422": *a3 "429": *a4 delete: @@ -5383,7 +5654,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "404": *a6 + "404": *a7 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/satisfy: @@ -5407,7 +5678,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "404": *a6 + "404": *a7 "422": *a3 "429": *a4 /api/labs: @@ -5554,7 +5825,7 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "404": &a7 + "404": &a8 description: Lab result not found (or owned by another user). content: application/json: @@ -5628,7 +5899,7 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "404": *a7 + "404": *a8 "422": *a3 "429": *a4 delete: @@ -5650,7 +5921,7 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "404": *a7 + "404": *a8 "422": *a3 "429": *a4 components: @@ -6548,6 +6819,33 @@ components: required: - disableCoach description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + ModulePrefsPatchRequest: + type: object + properties: + mood: + type: boolean + sleep: + type: boolean + glucose: + type: boolean + workouts: + type: boolean + recovery: + type: boolean + labs: + type: boolean + achievements: + type: boolean + insights: + type: boolean + doctorReport: + type: boolean + additionalProperties: false + description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, + `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a + core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422, so the + measurement engine + medications can never be disabled here. `cycle`/`coach` are accepted for forward-compat but + their real enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the operator assistant flag). ProfileUpdateRequest: type: object properties: @@ -12057,6 +12355,100 @@ components: - data - error additionalProperties: false + GetModulesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ModulesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ModulesResponse: + type: object + properties: + modules: + $ref: "#/components/schemas/ModuleMap" + required: + - modules + additionalProperties: false + ModuleMap: + type: object + properties: + cycle: + type: boolean + description: Whether the "cycle" module is enabled for this account. + mood: + type: boolean + description: Whether the "mood" module is enabled for this account. + sleep: + type: boolean + description: Whether the "sleep" module is enabled for this account. + glucose: + type: boolean + description: Whether the "glucose" module is enabled for this account. + workouts: + type: boolean + description: Whether the "workouts" module is enabled for this account. + recovery: + type: boolean + description: Whether the "recovery" module is enabled for this account. + labs: + type: boolean + description: Whether the "labs" module is enabled for this account. + achievements: + type: boolean + description: Whether the "achievements" module is enabled for this account. + coach: + type: boolean + description: Whether the "coach" module is enabled for this account. + insights: + type: boolean + description: Whether the "insights" module is enabled for this account. + doctorReport: + type: boolean + description: Whether the "doctorReport" module is enabled for this account. + required: + - cycle + - mood + - sleep + - glucose + - workouts + - recovery + - labs + - achievements + - coach + - insights + - doctorReport + additionalProperties: false + description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means + the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; + `coach` mirrors the resolved per-user opt-out + operator master flag. + PatchModulesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ModulesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetSourcePriorityResponse: type: object properties: @@ -13364,6 +13756,8 @@ components: - type: string - type: "null" description: German KVNR, decrypted for the form prefill. + modules: + $ref: "#/components/schemas/ModuleMap" required: - username - email @@ -13378,9 +13772,11 @@ components: - insurerName - insurerIkNumber - insuranceNumber + - modules additionalProperties: false description: Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) - is plaintext at rest. + is plaintext at rest. The `modules` map mirrors the /api/auth/me projection so the alias carries the same module + flags. PatchProfileResponse: type: object properties: @@ -13602,6 +13998,247 @@ components: - data - error additionalProperties: false + CoachConversationsPageResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CoachConversationsPage" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachConversationsPage: + type: object + properties: + conversations: + type: array + items: + $ref: "#/components/schemas/CoachConversation" + nextCursor: + anyOf: + - type: string + - type: "null" + description: Id of the last conversation on this page; pass it back as `cursor` for the next page. Null at the end of + the list. + required: + - conversations + - nextCursor + additionalProperties: false + description: Cursor-paginated page of the caller's Coach conversations, most-recent activity first. + CoachConversation: + type: object + properties: + id: + type: string + title: + type: string + description: Title summarised from the first user message. + createdAt: + 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)))$ + updatedAt: + 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)))$ + description: Bumped on every appended message; the rail orders by this. + messageCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - id + - title + - createdAt + - updatedAt + - messageCount + additionalProperties: false + description: Lightweight conversation metadata for the history rail. No message bodies — the rail does not decrypt. + CoachConversationDetailResponse: + type: object + properties: + data: + $ref: "#/components/schemas/CoachConversationDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachConversationDetail: + type: object + properties: + id: + type: string + title: + type: string + createdAt: + 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)))$ + updatedAt: + 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)))$ + messageCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + messages: + type: array + items: + $ref: "#/components/schemas/CoachMessage" + description: Every message in the conversation, decrypted, oldest-first. + summary: + description: Rolling summary of turns elided past the history window; null when none is on file. + anyOf: + - type: string + - type: "null" + required: + - id + - title + - createdAt + - updatedAt + - messageCount + - messages + additionalProperties: false + description: Full conversation with every message decrypted server-side. The client renders the bodies directly; no + decryption key is involved. + CoachMessage: + type: object + properties: + id: + type: string + role: + type: string + enum: + - user + - assistant + content: + type: string + description: Decrypted message body — the server decrypts on read. + createdAt: + 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)))$ + metricSource: + anyOf: + - $ref: "#/components/schemas/CoachProvenance" + - type: "null" + description: Provenance envelope (assistant turns); null for user turns. + providerType: + anyOf: + - type: string + - type: "null" + description: Provider that produced the reply (e.g. anthropic, openai, local, refusal); null for user turns. + promptVersion: + anyOf: + - type: string + - type: "null" + description: Coach prompt version that produced the reply; null for user turns. + required: + - id + - role + - content + - createdAt + - metricSource + - providerType + - promptVersion + additionalProperties: false + description: One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation. + CoachProvenance: + type: object + properties: + windows: + type: array + items: + type: string + enum: + - last7days + - last30days + - last90days + - lastYear + - allTime + description: Analysis windows the assistant drew on this turn. + metrics: + type: array + items: + type: string + description: Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot + sentinel. The client translates these labels; the server never localises them. + counts: + description: Per-metric sample-count summary; absent on an empty snapshot. + type: object + propertyNames: + type: string + additionalProperties: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + keyValues: + description: Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 + entries. + type: array + items: + type: object + properties: + label: + type: string + value: + type: string + unit: + type: string + window: + type: string + required: + - label + - value + additionalProperties: false + required: + - windows + - metrics + additionalProperties: false + description: Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence + key-values. No raw timestamps. + CoachConversationDeleted: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + description: Always true on success; a foreign / unknown id is a 404. + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false InsightsLayout: type: object properties: diff --git a/docs/doctor-report.md b/docs/doctor-report.md deleted file mode 100644 index c3743191e..000000000 --- a/docs/doctor-report.md +++ /dev/null @@ -1,76 +0,0 @@ -# Doctor Report - -The doctor report is a printable PDF summary of a user's tracked vitals, -medication compliance, glucose context, and mood over a configurable window -(default 90 days, max 365). It is intended as discussion material for a -doctor's appointment, **not** a medical diagnosis. - -## Two endpoints - -The same aggregation logic backs both endpoints — `collectDoctorReportData` -in `src/lib/doctor-report-data.ts` is the single source of truth — so the -JSON payload and the server-rendered PDF are guaranteed to be in sync. - -### `POST /api/doctor-report` — JSON payload - -Returns the aggregated `DoctorReportData` envelope. Used by the PWA's -client-side renderer (`src/lib/doctor-report-pdf.ts`, jsPDF + autotable). -Best on desktop browsers and on Android, where local generation keeps the -PDF off the wire and works offline (cached page). - -- Auth: session required. -- Rate-limit: 10/h per user. -- Body: `{ days?: number }` (default 90). -- Response: `200 application/json` with envelope `{ data, error: null }`. -- Audit action: `doctor-report.generate`. - -### `POST /api/doctor-report/pdf` — server-rendered PDF - -Returns the ready-to-download PDF bytes. Used primarily on iOS/Safari, where -the client-side jsPDF download UX is unreliable (Safari sometimes opens the -PDF in a new tab without preserving the filename or fails the blob save). - -- Auth: session required (`401` otherwise). -- Rate-limit: 10/h per user, shared with the JSON endpoint (`429` on excess). -- Body: `{ days?: number; locale?: "de" | "en" }`. Body is optional — empty - bodies are accepted. -- Locale resolution: explicit `body.locale` wins, then the - `Accept-Language` header (`de*` → `de`, anything else → `en`), then the - hard fallback `de`. -- Response: `200 application/pdf` with - `Content-Disposition: attachment; filename="healthlog-report-YYYY-MM-DD.pdf"` - and `Cache-Control: no-store`. Body is the raw PDF bytes. -- Errors: JSON envelope `{ data: null, error: "..." }` for `401` and `429`; - `500` if rendering fails. -- Audit action: `doctor-report.pdf.generate`. - -## Wellness summary (derived scores) - -Since v1.10.0 the report carries a **Wellness summary** section below the -clinical content, holding the server-derived wellness scores (Recovery, -Stress, Strain) and the other derived metrics where present. It is kept -deliberately separate from the clinical vitals table: - -- Each entry is labelled **descriptive, not a clinical assessment**, and the - scores never enter the vitals table or the clinical aggregations. -- The Stress score is shown as an HRV-derived proxy, not a sensor-measured - stress value. -- In the FHIR export the same scores are emitted as `survey`-category - `Observation`s (text-only `code`, no fabricated LOINC) with a per-Observation - non-clinical note — so a downstream system never mistakes them for a vital - sign or a diagnosis. - -Each derived metric states its inputs, method, and the cited standard it is -grounded in (the same provenance the in-app explainer shows). - -## Implementation notes - -- `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer. - jsPDF runs unchanged in Node — `doc.output("arraybuffer")` returns a valid - `%PDF-` byte stream in both environments, so we deliberately avoid headless - Chromium (Puppeteer/Playwright) to keep the runtime dependency surface low. -- `src/lib/doctor-report-pdf.ts` is a thin client façade that returns the - raw `jsPDF` instance so the existing settings-page download flow (which - calls `doc.save(...)`) keeps working unchanged. -- Server-side translations come from `messages/{de,en}.json` via - `getServerTranslator()` (`src/lib/i18n/server-translator.ts`). diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts index 3d708b2ca..4d913274d 100644 --- a/e2e/coach-guided-questions.spec.ts +++ b/e2e/coach-guided-questions.spec.ts @@ -143,7 +143,7 @@ test.describe("Coach guided clarifying questions", () => { "flow logic is viewport-independent; desktop run suffices", ); - await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); // 1. Entry card with the three choices. const offer = page.locator('[data-slot="coach-guided-offer"]'); @@ -197,7 +197,7 @@ test.describe("Coach guided clarifying questions", () => { "flow logic is viewport-independent; desktop run suffices", ); - await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); const offer = page.locator('[data-slot="coach-guided-offer"]'); await expect(offer).toBeVisible({ timeout: 10_000 }); @@ -216,7 +216,7 @@ test.describe("Coach guided clarifying questions", () => { "flow logic is viewport-independent; desktop run suffices", ); - await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + await page.goto("/coach", { waitUntil: "domcontentloaded" }); const offer = page.locator('[data-slot="coach-guided-offer"]'); await expect(offer).toBeVisible({ timeout: 10_000 }); diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index b0ae575a7..eb9735351 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -3,16 +3,17 @@ import { expect, test } from "@playwright/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** - * Settings → Export & Import consolidated UI smoke. + * Settings → Export & Import + Gesundheitsakte UI smoke. * * Validates that: - * 1. The export surfaces are all rendered with their stable testids: - * the health-record export hero, the four CSV/JSON tiles, and the - * demoted doctor-report card (v1.12). + * 1. The Export & Import surfaces (`/settings/export`) render with their + * stable testids: the four CSV/JSON tiles and the import cards. * 2. The import surfaces (v1.15.7, issue #281) render: the Apple Health * and generic-JSON cards. - * 3. The health-record "included data" checklist is a disclosure, - * collapsed by default, and expands on demand. + * 3. The full health-record export panel lives on its own top-level + * `/settings/gesundheitsakte` section (v1.18.0 S5) — including the + * "included data" checklist, a disclosure collapsed by default that + * expands on demand. * 4. Clicking the Measurements CSV download button fires a real * browser download — proving the `/api/export/measurements` * endpoint is reachable from the browser end-to-end. @@ -26,12 +27,10 @@ test.describe("Settings → Export & Import", () => { test("renders all export surfaces with stable testids", async ({ page }) => { await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); - // The health-record export is the page hero; the four CSV/backup - // tiles keep the `export-card-*` shape. The doctor report lives - // under the health-record export, so it is no longer a separate - // surface here. + // v1.18.0 (S5) — the full health-record export moved to its own + // `/settings/gesundheitsakte` section. The Export & Import page keeps + // the four CSV/backup tiles with the `export-card-*` shape. for (const id of [ - "health-record-export-panel", "export-card-measurements-csv", "export-card-medications-csv", "export-card-mood-csv", @@ -41,6 +40,19 @@ test.describe("Settings → Export & Import", () => { } }); + test("renders the health-record export panel on its own section", async ({ + page, + }) => { + // v1.18.0 (S5) — the health-record export is the hero of the + // dedicated, module-gated Gesundheitsakte section. + await page.goto("/settings/gesundheitsakte", { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("health-record-export-panel")).toBeVisible({ + timeout: 10_000, + }); + }); + test("renders the import surfaces with stable testids", async ({ page }) => { await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); // v1.15.7 (issue #281) — the Apple Health and generic-JSON import @@ -73,7 +85,11 @@ test.describe("Settings → Export & Import", () => { test("included-data checklist is collapsed by default and expands on demand", async ({ page, }) => { - await page.goto("/settings/export", { waitUntil: "domcontentloaded" }); + // v1.18.0 (S5) — the panel (and its disclosure) live on the + // dedicated Gesundheitsakte section now. + await page.goto("/settings/gesundheitsakte", { + waitUntil: "domcontentloaded", + }); const toggle = page.getByTestId("health-record-included-data-toggle"); await expect(toggle).toBeVisible({ timeout: 10_000 }); // Collapsed on first render — the checklist panel is absent. diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index af5dc2844..6f9d7f854 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -141,6 +141,26 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // Opt the seed account into cycle tracking. The user is seeded as + // `MALE`, and v1.18.0 resolves the `cycle` module through + // `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled` + // derives from gender, so a male account with no profile reads as + // cycle-OFF and `/cycle` redirects home. An explicit `true` opts the + // account in regardless of gender, which is what the cycle spec needs. + // Keyed by the username subquery so we never depend on the cuid above + // (the user upsert may have hit the ON CONFLICT path on a re-run). + await pool.query( + `INSERT INTO cycle_profiles + (id, user_id, cycle_tracking_enabled, created_at, updated_at) + SELECT $1, u.id, true, $2, $2 + FROM users u + WHERE u.username = $3 + ON CONFLICT (user_id) DO UPDATE SET + cycle_tracking_enabled = true, + updated_at = EXCLUDED.updated_at`, + [cuid(), now, E2E_USER.username], + ); + // Console (instead of structured logging) is intentional here — // global-setup runs outside the app's logging context, and the // line is useful when debugging a CI failure where the seed didn't diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 62177edf9..9616b30b0 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -24,7 +24,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * page underneath stays) and the dot clears. * * 4. The drawer's "Conversations" button no longer opens an in-panel - * tray; it hands off to the full-page route `/insights/coach`. + * tray; it hands off to the full-page route `/coach`. * * The three sub-pages covered (blutdruck, gewicht, schlaf) span the * empty-state branch (no data → CTA + Coach launch) and the populated @@ -258,7 +258,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await fab.click(); // v1.16.11 — the FAB opens the side drawer in place instead of - // navigating to `/insights/coach`; the page underneath stays. + // navigating to `/coach`; the page underneath stays. const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible({ timeout: 10_000 }); await expect(drawer).toHaveAttribute( @@ -267,7 +267,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { ? "bottom-sheet" : "side-sheet", ); - expect(new URL(page.url()).pathname).not.toContain("/insights/coach"); + expect(new URL(page.url()).pathname).not.toContain("/coach"); // Opening the Coach counts as reading the nudge on this device — // the dot clears and the unread flag drops off the FAB. @@ -281,7 +281,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { page, }, testInfo) => { // The in-panel history tray is gone from the drawer; the button - // navigates to `/insights/coach` where the conversation list + // navigates to `/coach` where the conversation list // renders inline on lg+. test.skip( testInfo.project.name !== "chromium-desktop", @@ -314,7 +314,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { await historyButton.click(); // No in-panel tray opens — the click navigates to the full view. - await page.waitForURL("**/insights/coach", { timeout: 10_000 }); + await page.waitForURL("**/coach", { timeout: 10_000 }); await expect( page.locator('[data-slot="coach-page"]'), ).toBeVisible({ timeout: 10_000 }); diff --git a/messages/de.json b/messages/de.json index c25559632..35c813012 100644 --- a/messages/de.json +++ b/messages/de.json @@ -189,7 +189,22 @@ "labsColValue": "Wert", "labsColReference": "Referenz", "labsColStatus": "Status", - "labsColDate": "Datum" + "labsColDate": "Datum", + "glucoseClinical": { + "title": "Glukose-Übersicht (klinisch)", + "colMetric": "Kennzahl", + "colValue": "Wert", + "tirInRange": "Zeit im Zielbereich (70–180 mg/dl)", + "tirLow": "Niedrig (< 70 mg/dl)", + "tirVeryLow": "Sehr niedrig (< 54 mg/dl)", + "tirHigh": "Hoch (> 180 mg/dl)", + "tirVeryHigh": "Sehr hoch (> 250 mg/dl)", + "mean": "Durchschnittliche Glukose", + "gmi": "Glukose-Management-Indikator (GMI)", + "eA1c": "Geschätzter HbA1c", + "cv": "Variabilität (VK%)", + "spotCaveat": "Dies sind Schätzwerte aus Einzelmessungen, kein Profil eines kontinuierlichen Messgeräts — als Tendenz und nicht als klinisches Urteil zu lesen." + } }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -2784,6 +2799,7 @@ } }, "navWorkouts": "Workouts", + "navRecovery": "Erholung", "workouts": { "title": "Workouts", "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.", @@ -3855,15 +3871,29 @@ }, "integrations": { "title": "Integrationen", - "description": "Withings, moodLog und weitere verbundene Dienste." + "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.", + "tabs": { + "connections": "Verbindungen", + "channels": "Kanäle", + "sources": "Quellen" + }, + "connections": { + "description": "Withings, WHOOP, Fitbit, Polar, Oura und Nightscout verbinden." + }, + "channels": { + "description": "Telegram, ntfy, Web-Push, Webhook und E-Mail – die Wege, über die Benachrichtigungen zugestellt werden." + } }, "notifications": { "title": "Benachrichtigungen", - "description": "Live-Übersicht aller konfigurierten Kanäle." + "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.", + "channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter", + "channelsHintLink": "Integrationen → Kanäle", + "inboxLink": "Posteingang öffnen" }, "layout": { "title": "Layout & Personalisierung", - "description": "Lege fest, wie deine App aufgebaut ist — ein Ort für Dashboard, Insights, Medikamente und Stimmung.", + "description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.", "backToHub": "Zurück zu Layout & Personalisierung", "dashboard": { "title": "Dashboard", @@ -3951,7 +3981,7 @@ }, "ai": { "title": "KI-Auswertungen", - "description": "Provider, Modell, API-Key." + "description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext." }, "api": { "title": "API & Tokens", @@ -3963,7 +3993,7 @@ }, "export": { "title": "Export & Import", - "description": "Lade deine Gesundheitsdaten als PDF, CSV oder vollständiges JSON-Backup herunter — oder importiere Daten aus Apple Health oder einer JSON-Datei.", + "description": "Lade deine Daten als CSV oder vollständiges JSON-Backup herunter — oder importiere aus Apple Health, JSON oder CSV. Den Arztbericht findest du jetzt unter Gesundheitsakte.", "otherOptionsHeading": "Weitere Export-Optionen", "downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.", "hero": { @@ -4086,41 +4116,36 @@ }, "medications": { "title": "Medikamente", - "description": "Listenansicht und Reihenfolge der Medikamente." + "description": "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente." }, "mood": { "title": "Stimmungs-Tags", - "description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl." + "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl." }, - "reminders": { - "title": "Erinnerungen & Benachrichtigungen", - "description": "Ein Ort für jede Erinnerung — woran und wann erinnert wird — und die Kanäle, die sie zustellen.", - "categoriesGroup": "Erinnerungen", - "channelsGroup": "Zustellkanäle", - "medication": { - "title": "Medikamenten-Erinnerungen", - "description": "Einnahmezeiten und Zeitfenster, je Medikament eingestellt." + "modules": { + "title": "Module", + "saved": "Gespeichert.", + "error": "Konnte nicht gespeichert werden. Bitte erneut versuchen.", + "toggleable": { + "title": "Was du trackst", + "description": "Optionale Bereiche. Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen — deine Daten bleiben erhalten." }, - "vorsorge": { - "title": "Vorsorge", - "description": "Wiederkehrende Untersuchungen und Messungen, inklusive Ort." + "core": { + "title": "Immer aktiv", + "description": "Die Grundlagen von HealthLog. Diese Bereiche lassen sich nicht abschalten.", + "note": "Kernbereich — immer aktiv" }, - "mood": { - "title": "Stimmungs-Check-in", - "description": "Eine tägliche Erinnerung, dein Befinden zu erfassen." - }, - "lowStock": { - "title": "Reichweite bei Restbestand", - "description": "Werde gewarnt, bevor ein Medikament zur Neige geht." - }, - "coach": { - "title": "Coach-Hinweis", - "description": "Proaktive Impulse vom KI-Coach." - }, - "channels": { - "title": "Benachrichtigungskanäle", - "description": "APNs, Telegram, ntfy, Web Push, Webhook und E-Mail." - } + "description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an.", + "manageIn": "In {section} verwalten", + "operatorDisabled": "Serverweit deaktiviert" + }, + "gesundheitsakte": { + "title": "Gesundheitsakte", + "description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren." + }, + "coach": { + "title": "Coach", + "description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen." } }, "profile": "Profil", @@ -4997,6 +5022,12 @@ "description": "Das ?-Symbol neben der Health-Score-Differenz. Die Zahl bleibt unabhängig davon sichtbar." } }, + "modules": { + "title": "Modulverfügbarkeit", + "description": "Schalte ein Modul für den gesamten Server ab. Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten.", + "disabledHint": "Serverweit deaktiviert", + "saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden." + }, "bugReportRepo": "Repository", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Issue-Token", @@ -6423,6 +6454,8 @@ "addButton": "Erinnerung hinzufügen", "markDone": "Erledigt", "delete": "Löschen", + "deleteConfirmTitle": "Erinnerung löschen?", + "deleteConfirmDescription": "Diese Vorsorge-Erinnerung wird dauerhaft gelöscht.", "disabledBadge": "Aus", "nextDue": { "none": "Nicht geplant", @@ -6479,6 +6512,8 @@ "referenceLabel": "Referenz", "readingsCount": "{count} Werte", "deleteLatest": "Letzten Wert löschen", + "deleteConfirmTitle": "Laborwert löschen?", + "deleteConfirmDescription": "Dieser Laborwert wird dauerhaft gelöscht.", "deletedToast": "Wert gelöscht", "deleteError": "Wert konnte nicht gelöscht werden", "loadError": "Laborwerte konnten nicht geladen werden", @@ -6510,5 +6545,67 @@ "savedToast": "Wert gespeichert", "saveError": "Wert konnte nicht gespeichert werden" } + }, + "modules": { + "cycle": { + "label": "Zyklus", + "description": "Periode, Phasen und Vorhersagen verfolgen." + }, + "mood": { + "label": "Stimmung", + "description": "Tägliche Stimmung und Tags erfassen." + }, + "sleep": { + "label": "Schlaf", + "description": "Schlafdauer, Phasen und Schlaf-Timeline." + }, + "glucose": { + "label": "Glukose", + "description": "Blutzuckerwerte und Verläufe." + }, + "workouts": { + "label": "Workouts", + "description": "Trainingseinheiten und Aktivität." + }, + "recovery": { + "label": "Erholung", + "description": "Erholungs- und Belastungswerte deiner Geräte." + }, + "labs": { + "label": "Laborwerte", + "description": "Laborergebnisse erfassen und im Verlauf ablegen." + }, + "achievements": { + "label": "Erfolge", + "description": "Abzeichen und Serien für deine Routine." + }, + "coach": { + "label": "Coach", + "description": "KI-Chat mit Bezug zu deinen Daten." + }, + "insights": { + "label": "Auswertungen", + "description": "KI-Analysen, Briefing und Korrelationen." + }, + "doctorReport": { + "label": "Arztbericht", + "description": "PDF- und FHIR-Export für deine Praxis." + }, + "weight": { + "label": "Gewicht", + "description": "Gewicht und Körperzusammensetzung." + }, + "bloodPressure": { + "label": "Blutdruck", + "description": "Systolisch und diastolisch." + }, + "pulse": { + "label": "Puls", + "description": "Ruhepuls und Herzfrequenz." + }, + "medications": { + "label": "Medikamente", + "description": "Pläne, Einnahmen und Compliance." + } } } diff --git a/messages/en.json b/messages/en.json index 2d774166a..ce511ebb8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -189,7 +189,22 @@ "labsColValue": "Value", "labsColReference": "Reference", "labsColStatus": "Status", - "labsColDate": "Date" + "labsColDate": "Date", + "glucoseClinical": { + "title": "Glucose overview (clinical)", + "colMetric": "Metric", + "colValue": "Value", + "tirInRange": "Time in range (70–180 mg/dL)", + "tirLow": "Low (< 70 mg/dL)", + "tirVeryLow": "Very low (< 54 mg/dL)", + "tirHigh": "High (> 180 mg/dL)", + "tirVeryHigh": "Very high (> 250 mg/dL)", + "mean": "Average glucose", + "gmi": "Glucose Management Indicator (GMI)", + "eA1c": "Estimated A1C", + "cv": "Variability (CV%)", + "spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict." + } }, "errorBoundary": { "title": "Something went wrong", @@ -2784,6 +2799,7 @@ } }, "navWorkouts": "Workouts", + "navRecovery": "Recovery", "workouts": { "title": "Workouts", "description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.", @@ -3686,7 +3702,7 @@ }, "integrations": { "title": "Connect your devices", - "body": "Open Settings to link Withings (auto-sync weight, BP and pulse), moodLog, or your Insights provider. Everything stays optional and encrypted." + "body": "Open Settings to link Withings (auto-sync weight, BP and pulse), moodLog, or your Insights provider. Everything stays optional and encrypted — and under Settings → Modules you can turn whole areas like sleep, mood or achievements on or off to keep HealthLog focused on what you track." }, "achievements": { "title": "Earn streaks and badges", @@ -3855,15 +3871,29 @@ }, "integrations": { "title": "Integrations", - "description": "Withings, moodLog, and other connected services." + "description": "Connected services, delivery channels, and source priority in one place.", + "tabs": { + "connections": "Connections", + "channels": "Channels", + "sources": "Sources" + }, + "connections": { + "description": "Connect Withings, WHOOP, Fitbit, Polar, Oura, and Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook, and Email — the routes notifications are delivered through." + } }, "notifications": { "title": "Notifications", - "description": "Live overview of every configured channel." + "description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under", + "channelsHintLink": "Integrations → Channels", + "inboxLink": "Open the inbox" }, "layout": { "title": "Layout & Personalization", - "description": "Arrange how your app is laid out — one home for the dashboard, insights, medications, and mood.", + "description": "Arrange how the dashboard and insights are laid out.", "backToHub": "Back to Layout & Personalization", "dashboard": { "title": "Dashboard", @@ -3951,7 +3981,7 @@ }, "ai": { "title": "AI Insights", - "description": "Provider, model, key." + "description": "Provider, model, and API key for the insights — plus your “about me” context." }, "api": { "title": "API & Tokens", @@ -3963,7 +3993,7 @@ }, "export": { "title": "Export & Import", - "description": "Download your health data as PDF, CSV, or a full JSON backup — or bring data in from Apple Health or a JSON file.", + "description": "Download your data as CSV or a full JSON backup — or import from Apple Health, JSON, or CSV. The doctor report now lives under Health Record.", "otherOptionsHeading": "Other export options", "downloadFailed": "Couldn't download the export. Please try again.", "hero": { @@ -4086,41 +4116,36 @@ }, "medications": { "title": "Medications", - "description": "List view and medication order." + "description": "List view, order, and injection sites for your medications." }, "mood": { "title": "Mood tags", - "description": "Groups, tags, and order of the mood picker." + "description": "Groups, tags, and archived tags for the mood picker." }, - "reminders": { - "title": "Reminders & Notifications", - "description": "One home for every reminder — what gets reminded and when — plus the channels that deliver them.", - "categoriesGroup": "Reminders", - "channelsGroup": "Delivery channels", - "medication": { - "title": "Medication reminders", - "description": "Dose times and windows, configured on each medication." + "modules": { + "title": "Modules", + "saved": "Saved.", + "error": "Couldn't save. Please try again.", + "toggleable": { + "title": "What you track", + "description": "Optional areas. Disabled modules drop out of the navigation, tiles, and insights — your data stays untouched." }, - "vorsorge": { - "title": "Preventive care", - "description": "Recurring check-ups and measurements, with where to go." + "core": { + "title": "Always on", + "description": "HealthLog's foundations. These areas can't be turned off.", + "note": "Core area — always on" }, - "mood": { - "title": "Mood check-in", - "description": "A daily nudge to log how you feel." - }, - "lowStock": { - "title": "Low-stock runway", - "description": "Get warned before a medication runs out." - }, - "coach": { - "title": "Coach nudge", - "description": "Proactive prompts from the AI coach." - }, - "channels": { - "title": "Notification channels", - "description": "APNs, Telegram, ntfy, Web Push, Webhook, and Email." - } + "description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away.", + "manageIn": "Manage in {section}", + "operatorDisabled": "Disabled server-wide" + }, + "gesundheitsakte": { + "title": "Health record", + "description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package." + }, + "coach": { + "title": "Coach", + "description": "Enable the Coach, tone and scope, stored memories." } }, "profile": "Profile", @@ -4997,6 +5022,12 @@ "description": "The ? glyph next to the Health-Score delta line. The delta digit stays visible regardless." } }, + "modules": { + "title": "Module availability", + "description": "Turn a module off for the whole server. A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled.", + "disabledHint": "Disabled server-wide", + "saveError": "Could not update module availability." + }, "bugReportRepo": "Repository", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Issue token", @@ -6423,6 +6454,8 @@ "addButton": "Add reminder", "markDone": "Done", "delete": "Delete", + "deleteConfirmTitle": "Delete reminder?", + "deleteConfirmDescription": "This preventive-care reminder will be permanently deleted.", "disabledBadge": "Off", "nextDue": { "none": "Not scheduled", @@ -6479,6 +6512,8 @@ "referenceLabel": "Reference", "readingsCount": "{count} readings", "deleteLatest": "Delete latest reading", + "deleteConfirmTitle": "Delete lab result?", + "deleteConfirmDescription": "This lab result will be permanently deleted.", "deletedToast": "Result deleted", "deleteError": "Could not delete the result", "loadError": "Could not load lab results", @@ -6510,5 +6545,67 @@ "savedToast": "Result saved", "saveError": "Could not save the result" } + }, + "modules": { + "cycle": { + "label": "Cycle", + "description": "Track period, phases, and predictions." + }, + "mood": { + "label": "Mood", + "description": "Log daily mood and tags." + }, + "sleep": { + "label": "Sleep", + "description": "Sleep duration, stages, and timeline." + }, + "glucose": { + "label": "Glucose", + "description": "Blood-glucose readings and trends." + }, + "workouts": { + "label": "Workouts", + "description": "Training sessions and activity." + }, + "recovery": { + "label": "Recovery", + "description": "Recovery and strain metrics from your devices." + }, + "labs": { + "label": "Lab results", + "description": "Capture lab results and keep their history." + }, + "achievements": { + "label": "Achievements", + "description": "Badges and streaks for your routine." + }, + "coach": { + "label": "Coach", + "description": "AI chat grounded in your own data." + }, + "insights": { + "label": "Insights", + "description": "AI analysis, briefing, and correlations." + }, + "doctorReport": { + "label": "Doctor report", + "description": "PDF and FHIR export for your practice." + }, + "weight": { + "label": "Weight", + "description": "Weight and body composition." + }, + "bloodPressure": { + "label": "Blood pressure", + "description": "Systolic and diastolic." + }, + "pulse": { + "label": "Pulse", + "description": "Resting pulse and heart rate." + }, + "medications": { + "label": "Medications", + "description": "Schedules, intakes, and compliance." + } } } diff --git a/messages/es.json b/messages/es.json index 21d3c6f22..083fee1bc 100644 --- a/messages/es.json +++ b/messages/es.json @@ -189,7 +189,22 @@ "labsColValue": "Valor", "labsColReference": "Referencia", "labsColStatus": "Estado", - "labsColDate": "Fecha" + "labsColDate": "Fecha", + "glucoseClinical": { + "title": "Resumen de glucosa (clínico)", + "colMetric": "Métrica", + "colValue": "Valor", + "tirInRange": "Tiempo en rango (70–180 mg/dl)", + "tirLow": "Bajo (< 70 mg/dl)", + "tirVeryLow": "Muy bajo (< 54 mg/dl)", + "tirHigh": "Alto (> 180 mg/dl)", + "tirVeryHigh": "Muy alto (> 250 mg/dl)", + "mean": "Glucosa media", + "gmi": "Indicador de gestión de la glucosa (GMI)", + "eA1c": "A1C estimada", + "cv": "Variabilidad (CV%)", + "spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de monitor continuo — léelas como una tendencia, no como un veredicto clínico." + } }, "errorBoundary": { "title": "Algo ha salido mal", @@ -2784,6 +2799,7 @@ } }, "navWorkouts": "Entrenamientos", + "navRecovery": "Recuperación", "workouts": { "title": "Entrenamientos", "description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.", @@ -3855,15 +3871,29 @@ }, "integrations": { "title": "Integraciones", - "description": "Withings, moodLog y otros servicios conectados." + "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.", + "tabs": { + "connections": "Conexiones", + "channels": "Canales", + "sources": "Fuentes" + }, + "connections": { + "description": "Conecta Withings, WHOOP, Fitbit, Polar, Oura y Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook y correo: las vías por las que se entregan las notificaciones." + } }, "notifications": { "title": "Notificaciones", - "description": "Vista en vivo de cada canal configurado." + "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en", + "channelsHintLink": "Integraciones → Canales", + "inboxLink": "Abrir la bandeja de entrada" }, "layout": { "title": "Diseño y personalización", - "description": "Organiza cómo está dispuesta tu app — un único lugar para el panel, los análisis, los medicamentos y el ánimo.", + "description": "Organiza cómo están dispuestos el panel y los análisis.", "backToHub": "Volver a Diseño y personalización", "dashboard": { "title": "Panel", @@ -3951,7 +3981,7 @@ }, "ai": { "title": "Análisis con IA", - "description": "Proveedor, modelo, clave." + "description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí»." }, "api": { "title": "API & Tokens", @@ -3963,7 +3993,7 @@ }, "export": { "title": "Exportar e importar", - "description": "Descarga tus datos de salud como PDF, CSV o una copia de seguridad JSON completa, o importa datos desde Apple Health o un archivo JSON.", + "description": "Descarga tus datos como CSV o una copia de seguridad JSON completa, o importa desde Apple Health, JSON o CSV. El informe médico ahora está en Historia de salud.", "otherOptionsHeading": "Otras opciones de exportación", "downloadFailed": "No se pudo descargar la exportación. Inténtalo de nuevo.", "hero": { @@ -4086,41 +4116,36 @@ }, "medications": { "title": "Medicamentos", - "description": "Vista de lista y orden de los medicamentos." + "description": "Vista de lista, orden y zonas de inyección de tus medicamentos." }, "mood": { "title": "Etiquetas de ánimo", - "description": "Grupos, etiquetas y orden del selector de ánimo." + "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo." }, - "reminders": { - "title": "Recordatorios y notificaciones", - "description": "Un único lugar para cada recordatorio — qué se recuerda y cuándo — y los canales que lo entregan.", - "categoriesGroup": "Recordatorios", - "channelsGroup": "Canales de entrega", - "medication": { - "title": "Recordatorios de medicación", - "description": "Horas y ventanas de dosis, configuradas en cada medicamento." + "modules": { + "title": "Módulos", + "saved": "Guardado.", + "error": "No se pudo guardar. Inténtalo de nuevo.", + "toggleable": { + "title": "Qué registras", + "description": "Áreas opcionales. Los módulos desactivados desaparecen de la navegación, los paneles y los análisis; tus datos se conservan." }, - "vorsorge": { - "title": "Cuidado preventivo", - "description": "Revisiones y mediciones periódicas, con el lugar al que acudir." + "core": { + "title": "Siempre activos", + "description": "Los pilares de HealthLog. Estas áreas no se pueden desactivar.", + "note": "Área esencial — siempre activa" }, - "mood": { - "title": "Registro de ánimo", - "description": "Un aviso diario para registrar cómo te sientes." - }, - "lowStock": { - "title": "Margen por bajo stock", - "description": "Recibe un aviso antes de que se acabe un medicamento." - }, - "coach": { - "title": "Sugerencia del coach", - "description": "Indicaciones proactivas del coach de IA." - }, - "channels": { - "title": "Canales de notificación", - "description": "APNs, Telegram, ntfy, Web Push, Webhook y correo." - } + "description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante.", + "manageIn": "Gestionar en {section}", + "operatorDisabled": "Desactivado en todo el servidor" + }, + "gesundheitsakte": { + "title": "Historial médico", + "description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado." + }, + "coach": { + "title": "Coach", + "description": "Activa el Coach, tono y alcance, recuerdos guardados." } }, "profile": "Perfil", @@ -4997,6 +5022,12 @@ "description": "El icono ? junto a la línea de delta del Health-Score. El dígito del delta permanece visible siempre." } }, + "modules": { + "title": "Disponibilidad de módulos", + "description": "Desactiva un módulo para todo el servidor. Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar.", + "disabledHint": "Desactivado en todo el servidor", + "saveError": "No se pudo actualizar la disponibilidad de módulos." + }, "bugReportRepo": "Repositorio", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token de issues", @@ -6423,6 +6454,8 @@ "addButton": "Añadir recordatorio", "markDone": "Hecho", "delete": "Eliminar", + "deleteConfirmTitle": "¿Eliminar recordatorio?", + "deleteConfirmDescription": "Este recordatorio de prevención se eliminará de forma permanente.", "disabledBadge": "Apagado", "nextDue": { "none": "Sin programar", @@ -6479,6 +6512,8 @@ "referenceLabel": "Referencia", "readingsCount": "{count} valores", "deleteLatest": "Eliminar el último valor", + "deleteConfirmTitle": "¿Eliminar resultado de laboratorio?", + "deleteConfirmDescription": "Este resultado de laboratorio se eliminará de forma permanente.", "deletedToast": "Resultado eliminado", "deleteError": "No se pudo eliminar el resultado", "loadError": "No se pudieron cargar los resultados", @@ -6510,5 +6545,67 @@ "savedToast": "Resultado guardado", "saveError": "No se pudo guardar el resultado" } + }, + "modules": { + "cycle": { + "label": "Ciclo", + "description": "Registra periodo, fases y predicciones." + }, + "mood": { + "label": "Estado de ánimo", + "description": "Registra el ánimo diario y etiquetas." + }, + "sleep": { + "label": "Sueño", + "description": "Duración, fases y cronología del sueño." + }, + "glucose": { + "label": "Glucosa", + "description": "Lecturas de glucosa y tendencias." + }, + "workouts": { + "label": "Entrenamientos", + "description": "Sesiones de entrenamiento y actividad." + }, + "recovery": { + "label": "Recuperación", + "description": "Métricas de recuperación y carga de tus dispositivos." + }, + "labs": { + "label": "Resultados de laboratorio", + "description": "Registra resultados de laboratorio y su historial." + }, + "achievements": { + "label": "Logros", + "description": "Insignias y rachas para tu rutina." + }, + "coach": { + "label": "Coach", + "description": "Chat de IA basado en tus propios datos." + }, + "insights": { + "label": "Análisis", + "description": "Análisis de IA, resumen y correlaciones." + }, + "doctorReport": { + "label": "Informe médico", + "description": "Exportación PDF y FHIR para tu consulta." + }, + "weight": { + "label": "Peso", + "description": "Peso y composición corporal." + }, + "bloodPressure": { + "label": "Presión arterial", + "description": "Sistólica y diastólica." + }, + "pulse": { + "label": "Pulso", + "description": "Pulso en reposo y frecuencia cardiaca." + }, + "medications": { + "label": "Medicamentos", + "description": "Pautas, tomas y cumplimiento." + } } } diff --git a/messages/fr.json b/messages/fr.json index e75419797..9b837a2c6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -189,7 +189,22 @@ "labsColValue": "Valeur", "labsColReference": "Référence", "labsColStatus": "Statut", - "labsColDate": "Date" + "labsColDate": "Date", + "glucoseClinical": { + "title": "Aperçu glycémique (clinique)", + "colMetric": "Indicateur", + "colValue": "Valeur", + "tirInRange": "Temps dans la cible (70–180 mg/dl)", + "tirLow": "Bas (< 70 mg/dl)", + "tirVeryLow": "Très bas (< 54 mg/dl)", + "tirHigh": "Élevé (> 180 mg/dl)", + "tirVeryHigh": "Très élevé (> 250 mg/dl)", + "mean": "Glycémie moyenne", + "gmi": "Indicateur de gestion du glucose (GMI)", + "eA1c": "HbA1c estimée", + "cv": "Variabilité (CV%)", + "spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, et non un profil de capteur continu — à lire comme une tendance, pas comme un verdict clinique." + } }, "errorBoundary": { "title": "Une erreur est survenue", @@ -2784,6 +2799,7 @@ } }, "navWorkouts": "Entraînements", + "navRecovery": "Récupération", "workouts": { "title": "Entraînements", "description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.", @@ -3855,15 +3871,29 @@ }, "integrations": { "title": "Intégrations", - "description": "Withings, moodLog et autres services connectés." + "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.", + "tabs": { + "connections": "Connexions", + "channels": "Canaux", + "sources": "Sources" + }, + "connections": { + "description": "Connecter Withings, WHOOP, Fitbit, Polar, Oura et Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook et e-mail : les voies par lesquelles les notifications sont envoyées." + } }, "notifications": { "title": "Notifications", - "description": "Aperçu en direct de chaque canal configuré." + "description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans", + "channelsHintLink": "Intégrations → Canaux", + "inboxLink": "Ouvrir la boîte de réception" }, "layout": { "title": "Disposition et personnalisation", - "description": "Organisez la disposition de votre application — un seul endroit pour le tableau de bord, les analyses, les médicaments et l’humeur.", + "description": "Organisez la disposition du tableau de bord et des analyses.", "backToHub": "Retour à Disposition et personnalisation", "dashboard": { "title": "Tableau de bord", @@ -3951,7 +3981,7 @@ }, "ai": { "title": "Analyses IA", - "description": "Fournisseur, modèle, clé." + "description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi »." }, "api": { "title": "API & Tokens", @@ -3963,7 +3993,7 @@ }, "export": { "title": "Export et import", - "description": "Téléchargez vos données de santé en PDF, CSV ou sauvegarde JSON complète, ou importez des données depuis Apple Santé ou un fichier JSON.", + "description": "Téléchargez vos données en CSV ou en sauvegarde JSON complète, ou importez depuis Apple Santé, JSON ou CSV. Le compte rendu médical se trouve désormais dans Dossier de santé.", "otherOptionsHeading": "Autres options d'export", "downloadFailed": "Échec du téléchargement de l'export. Veuillez réessayer.", "hero": { @@ -4086,41 +4116,36 @@ }, "medications": { "title": "Médicaments", - "description": "Vue de la liste et ordre des médicaments." + "description": "Vue liste, ordre et sites d’injection de vos médicaments." }, "mood": { "title": "Étiquettes d’humeur", - "description": "Groupes, étiquettes et ordre du sélecteur d’humeur." + "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur." }, - "reminders": { - "title": "Rappels et notifications", - "description": "Un seul endroit pour chaque rappel — quoi et quand — ainsi que les canaux qui les délivrent.", - "categoriesGroup": "Rappels", - "channelsGroup": "Canaux de diffusion", - "medication": { - "title": "Rappels de médication", - "description": "Heures et fenêtres de prise, configurées sur chaque médicament." + "modules": { + "title": "Modules", + "saved": "Enregistré.", + "error": "Échec de l'enregistrement. Veuillez réessayer.", + "toggleable": { + "title": "Ce que vous suivez", + "description": "Domaines optionnels. Les modules désactivés disparaissent de la navigation, des tuiles et des analyses ; vos données sont conservées." }, - "vorsorge": { - "title": "Prévention", - "description": "Bilans et mesures récurrents, avec le lieu où se rendre." + "core": { + "title": "Toujours actifs", + "description": "Les fondations de HealthLog. Ces domaines ne peuvent pas être désactivés.", + "note": "Domaine essentiel — toujours actif" }, - "mood": { - "title": "Suivi de l'humeur", - "description": "Un rappel quotidien pour noter votre ressenti." - }, - "lowStock": { - "title": "Marge de stock faible", - "description": "Soyez averti avant qu'un médicament ne s'épuise." - }, - "coach": { - "title": "Suggestion du coach", - "description": "Invitations proactives du coach IA." - }, - "channels": { - "title": "Canaux de notification", - "description": "APNs, Telegram, ntfy, Web Push, Webhook et e-mail." - } + "description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt.", + "manageIn": "Gérer dans {section}", + "operatorDisabled": "Désactivé sur tout le serveur" + }, + "gesundheitsakte": { + "title": "Dossier médical", + "description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné." + }, + "coach": { + "title": "Coach", + "description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés." } }, "profile": "Profil", @@ -4997,6 +5022,12 @@ "description": "L'icône ? près de la ligne de delta du Health-Score. Le chiffre du delta reste visible quoi qu'il arrive." } }, + "modules": { + "title": "Disponibilité des modules", + "description": "Désactivez un module pour tout le serveur. Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés.", + "disabledHint": "Désactivé sur tout le serveur", + "saveError": "Impossible de mettre à jour la disponibilité des modules." + }, "bugReportRepo": "Dépôt", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Jeton d'issue", @@ -6423,6 +6454,8 @@ "addButton": "Ajouter un rappel", "markDone": "Terminé", "delete": "Supprimer", + "deleteConfirmTitle": "Supprimer le rappel ?", + "deleteConfirmDescription": "Ce rappel de prévention sera définitivement supprimé.", "disabledBadge": "Désactivé", "nextDue": { "none": "Non planifié", @@ -6479,6 +6512,8 @@ "referenceLabel": "Référence", "readingsCount": "{count} valeurs", "deleteLatest": "Supprimer la dernière valeur", + "deleteConfirmTitle": "Supprimer le résultat d'analyse ?", + "deleteConfirmDescription": "Ce résultat d'analyse sera définitivement supprimé.", "deletedToast": "Résultat supprimé", "deleteError": "Impossible de supprimer le résultat", "loadError": "Impossible de charger les résultats", @@ -6510,5 +6545,67 @@ "savedToast": "Résultat enregistré", "saveError": "Impossible d'enregistrer le résultat" } + }, + "modules": { + "cycle": { + "label": "Cycle", + "description": "Suivez règles, phases et prévisions." + }, + "mood": { + "label": "Humeur", + "description": "Notez l'humeur quotidienne et les tags." + }, + "sleep": { + "label": "Sommeil", + "description": "Durée, phases et chronologie du sommeil." + }, + "glucose": { + "label": "Glycémie", + "description": "Mesures de glycémie et tendances." + }, + "workouts": { + "label": "Entraînements", + "description": "Séances d'entraînement et activité." + }, + "recovery": { + "label": "Récupération", + "description": "Indicateurs de récupération et de charge de vos appareils." + }, + "labs": { + "label": "Résultats de labo", + "description": "Enregistrez les résultats de laboratoire et leur historique." + }, + "achievements": { + "label": "Réussites", + "description": "Badges et séries pour votre routine." + }, + "coach": { + "label": "Coach", + "description": "Chat IA fondé sur vos propres données." + }, + "insights": { + "label": "Analyses", + "description": "Analyses IA, briefing et corrélations." + }, + "doctorReport": { + "label": "Rapport médical", + "description": "Export PDF et FHIR pour votre cabinet." + }, + "weight": { + "label": "Poids", + "description": "Poids et composition corporelle." + }, + "bloodPressure": { + "label": "Tension artérielle", + "description": "Systolique et diastolique." + }, + "pulse": { + "label": "Pouls", + "description": "Pouls au repos et fréquence cardiaque." + }, + "medications": { + "label": "Médicaments", + "description": "Schémas, prises et observance." + } } } diff --git a/messages/it.json b/messages/it.json index ca7636642..23305e7da 100644 --- a/messages/it.json +++ b/messages/it.json @@ -189,7 +189,22 @@ "labsColValue": "Valore", "labsColReference": "Riferimento", "labsColStatus": "Stato", - "labsColDate": "Data" + "labsColDate": "Data", + "glucoseClinical": { + "title": "Panoramica glicemia (clinica)", + "colMetric": "Metrica", + "colValue": "Valore", + "tirInRange": "Tempo nell'intervallo (70–180 mg/dl)", + "tirLow": "Basso (< 70 mg/dl)", + "tirVeryLow": "Molto basso (< 54 mg/dl)", + "tirHigh": "Alto (> 180 mg/dl)", + "tirVeryHigh": "Molto alto (> 250 mg/dl)", + "mean": "Glicemia media", + "gmi": "Indicatore di gestione del glucosio (GMI)", + "eA1c": "A1C stimata", + "cv": "Variabilità (CV%)", + "spotCaveat": "Sono stime da misurazioni occasionali, non un profilo da monitor continuo — vanno lette come una tendenza, non come un verdetto clinico." + } }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -2784,6 +2799,7 @@ } }, "navWorkouts": "Allenamenti", + "navRecovery": "Recupero", "workouts": { "title": "Allenamenti", "description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.", @@ -3855,15 +3871,29 @@ }, "integrations": { "title": "Integrazioni", - "description": "Withings, moodLog e altri servizi collegati." + "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.", + "tabs": { + "connections": "Connessioni", + "channels": "Canali", + "sources": "Origini" + }, + "connections": { + "description": "Collega Withings, WHOOP, Fitbit, Polar, Oura e Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook ed e-mail: le vie attraverso cui vengono recapitate le notifiche." + } }, "notifications": { "title": "Notifiche", - "description": "Panoramica in tempo reale di ogni canale configurato." + "description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in", + "channelsHintLink": "Integrazioni → Canali", + "inboxLink": "Apri la posta in arrivo" }, "layout": { "title": "Layout e personalizzazione", - "description": "Organizza la disposizione della tua app — un unico posto per cruscotto, approfondimenti, farmaci e umore.", + "description": "Organizza la disposizione del cruscotto e degli approfondimenti.", "backToHub": "Torna a Layout e personalizzazione", "dashboard": { "title": "Cruscotto", @@ -3951,7 +3981,7 @@ }, "ai": { "title": "Analisi IA", - "description": "Provider, modello, chiave." + "description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me»." }, "api": { "title": "API & Tokens", @@ -3963,7 +3993,7 @@ }, "export": { "title": "Esporta e importa", - "description": "Scarica i tuoi dati sanitari come PDF, CSV o backup JSON completo, oppure importa dati da Apple Salute o da un file JSON.", + "description": "Scarica i tuoi dati come CSV o backup JSON completo, oppure importa da Apple Salute, JSON o CSV. Il referto medico ora si trova in Cartella sanitaria.", "otherOptionsHeading": "Altre opzioni di esportazione", "downloadFailed": "Impossibile scaricare l'esportazione. Riprova.", "hero": { @@ -4086,41 +4116,36 @@ }, "medications": { "title": "Farmaci", - "description": "Vista elenco e ordine dei farmaci." + "description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci." }, "mood": { "title": "Tag dell’umore", - "description": "Gruppi, tag e ordine del selettore dell’umore." + "description": "Gruppi, tag e tag archiviati del selettore dell’umore." }, - "reminders": { - "title": "Promemoria e notifiche", - "description": "Un'unica sede per ogni promemoria — cosa ricordare e quando — e i canali che li recapitano.", - "categoriesGroup": "Promemoria", - "channelsGroup": "Canali di consegna", - "medication": { - "title": "Promemoria dei farmaci", - "description": "Orari e finestre delle dosi, impostati su ogni farmaco." + "modules": { + "title": "Moduli", + "saved": "Salvato.", + "error": "Salvataggio non riuscito. Riprova.", + "toggleable": { + "title": "Cosa monitori", + "description": "Ambiti opzionali. I moduli disattivati spariscono da navigazione, riquadri e analisi; i tuoi dati restano intatti." }, - "vorsorge": { - "title": "Prevenzione", - "description": "Controlli e misurazioni ricorrenti, con il luogo dove recarsi." + "core": { + "title": "Sempre attivi", + "description": "Le fondamenta di HealthLog. Questi ambiti non possono essere disattivati.", + "note": "Ambito di base — sempre attivo" }, - "mood": { - "title": "Check-in dell'umore", - "description": "Un promemoria quotidiano per registrare come ti senti." - }, - "lowStock": { - "title": "Autonomia scorte", - "description": "Ricevi un avviso prima che un farmaco finisca." - }, - "coach": { - "title": "Suggerimento del coach", - "description": "Stimoli proattivi dal coach IA." - }, - "channels": { - "title": "Canali di notifica", - "description": "APNs, Telegram, ntfy, Web Push, Webhook ed e-mail." - } + "description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito.", + "manageIn": "Gestisci in {section}", + "operatorDisabled": "Disattivato a livello di server" + }, + "gesundheitsakte": { + "title": "Cartella clinica", + "description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato." + }, + "coach": { + "title": "Coach", + "description": "Attiva il Coach, tono e ambito, ricordi salvati." } }, "profile": "Profilo", @@ -4997,6 +5022,12 @@ "description": "L'icona ? accanto alla riga del delta dell'Health-Score. La cifra del delta resta visibile in ogni caso." } }, + "modules": { + "title": "Disponibilità dei moduli", + "description": "Disattiva un modulo per l'intero server. Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati.", + "disabledHint": "Disattivato a livello di server", + "saveError": "Impossibile aggiornare la disponibilità dei moduli." + }, "bugReportRepo": "Repository", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token delle issue", @@ -6423,6 +6454,8 @@ "addButton": "Aggiungi promemoria", "markDone": "Fatto", "delete": "Elimina", + "deleteConfirmTitle": "Eliminare il promemoria?", + "deleteConfirmDescription": "Questo promemoria di prevenzione verrà eliminato definitivamente.", "disabledBadge": "Disattivato", "nextDue": { "none": "Non pianificato", @@ -6479,6 +6512,8 @@ "referenceLabel": "Riferimento", "readingsCount": "{count} valori", "deleteLatest": "Elimina l'ultimo valore", + "deleteConfirmTitle": "Eliminare il risultato di laboratorio?", + "deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato definitivamente.", "deletedToast": "Risultato eliminato", "deleteError": "Impossibile eliminare il risultato", "loadError": "Impossibile caricare i risultati", @@ -6510,5 +6545,67 @@ "savedToast": "Risultato salvato", "saveError": "Impossibile salvare il risultato" } + }, + "modules": { + "cycle": { + "label": "Ciclo", + "description": "Monitora mestruazioni, fasi e previsioni." + }, + "mood": { + "label": "Umore", + "description": "Registra umore quotidiano ed etichette." + }, + "sleep": { + "label": "Sonno", + "description": "Durata, fasi e cronologia del sonno." + }, + "glucose": { + "label": "Glicemia", + "description": "Valori della glicemia e andamenti." + }, + "workouts": { + "label": "Allenamenti", + "description": "Sessioni di allenamento e attività." + }, + "recovery": { + "label": "Recupero", + "description": "Metriche di recupero e carico dai tuoi dispositivi." + }, + "labs": { + "label": "Esami di laboratorio", + "description": "Registra i referti di laboratorio e il loro storico." + }, + "achievements": { + "label": "Traguardi", + "description": "Badge e serie per la tua routine." + }, + "coach": { + "label": "Coach", + "description": "Chat IA basata sui tuoi dati." + }, + "insights": { + "label": "Analisi", + "description": "Analisi IA, briefing e correlazioni." + }, + "doctorReport": { + "label": "Referto medico", + "description": "Esportazione PDF e FHIR per il tuo studio." + }, + "weight": { + "label": "Peso", + "description": "Peso e composizione corporea." + }, + "bloodPressure": { + "label": "Pressione", + "description": "Sistolica e diastolica." + }, + "pulse": { + "label": "Battito", + "description": "Battito a riposo e frequenza cardiaca." + }, + "medications": { + "label": "Farmaci", + "description": "Schemi, assunzioni e aderenza." + } } } diff --git a/messages/pl.json b/messages/pl.json index e9c9ca571..8e1ff3360 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -189,7 +189,22 @@ "labsColValue": "Wartość", "labsColReference": "Norma", "labsColStatus": "Status", - "labsColDate": "Data" + "labsColDate": "Data", + "glucoseClinical": { + "title": "Przegląd glukozy (kliniczny)", + "colMetric": "Wskaźnik", + "colValue": "Wartość", + "tirInRange": "Czas w zakresie (70–180 mg/dl)", + "tirLow": "Niski (< 70 mg/dl)", + "tirVeryLow": "Bardzo niski (< 54 mg/dl)", + "tirHigh": "Wysoki (> 180 mg/dl)", + "tirVeryHigh": "Bardzo wysoki (> 250 mg/dl)", + "mean": "Średnia glukoza", + "gmi": "Wskaźnik zarządzania glukozą (GMI)", + "eA1c": "Szacowane HbA1c", + "cv": "Zmienność (CV%)", + "spotCaveat": "To szacunki z pojedynczych pomiarów, nie profil z monitora ciągłego — czytaj je jako kierunek zmian, a nie kliniczny werdykt." + } }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -2784,6 +2799,7 @@ } }, "navWorkouts": "Treningi", + "navRecovery": "Regeneracja", "workouts": { "title": "Treningi", "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.", @@ -3855,15 +3871,29 @@ }, "integrations": { "title": "Integracje", - "description": "Withings, moodLog i inne połączone usługi." + "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.", + "tabs": { + "connections": "Połączenia", + "channels": "Kanały", + "sources": "Źródła" + }, + "connections": { + "description": "Połącz Withings, WHOOP, Fitbit, Polar, Oura i Nightscout." + }, + "channels": { + "description": "Telegram, ntfy, Web Push, Webhook i e-mail — drogi dostarczania powiadomień." + } }, "notifications": { "title": "Powiadomienia", - "description": "Podgląd na żywo każdego skonfigurowanego kanału." + "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.", + "channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w", + "channelsHintLink": "Integracje → Kanały", + "inboxLink": "Otwórz skrzynkę odbiorczą" }, "layout": { "title": "Układ i personalizacja", - "description": "Ustal, jak rozłożona jest Twoja aplikacja — jedno miejsce dla pulpitu, analiz, leków i nastroju.", + "description": "Ustal, jak rozłożone są pulpit i analizy.", "backToHub": "Powrót do Układ i personalizacja", "dashboard": { "title": "Pulpit", @@ -3951,7 +3981,7 @@ }, "ai": { "title": "Analizy AI", - "description": "Dostawca, model, klucz." + "description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”." }, "api": { "title": "API & Tokens", @@ -3963,7 +3993,7 @@ }, "export": { "title": "Eksport i import", - "description": "Pobierz swoje dane zdrowotne jako PDF, CSV lub pełną kopię zapasową JSON albo zaimportuj dane z Apple Health lub pliku JSON.", + "description": "Pobierz swoje dane jako CSV lub pełną kopię zapasową JSON albo zaimportuj z Apple Health, JSON lub CSV. Raport dla lekarza znajdziesz teraz w Dokumentacji zdrowotnej.", "otherOptionsHeading": "Inne opcje eksportu", "downloadFailed": "Nie udało się pobrać eksportu. Spróbuj ponownie.", "hero": { @@ -4086,41 +4116,36 @@ }, "medications": { "title": "Leki", - "description": "Widok listy i kolejność leków." + "description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków." }, "mood": { "title": "Tagi nastroju", - "description": "Grupy, tagi i kolejność w wyborze nastroju." + "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju." }, - "reminders": { - "title": "Przypomnienia i powiadomienia", - "description": "Jedno miejsce dla każdego przypomnienia — o czym i kiedy — oraz kanały, które je dostarczają.", - "categoriesGroup": "Przypomnienia", - "channelsGroup": "Kanały dostarczania", - "medication": { - "title": "Przypomnienia o lekach", - "description": "Godziny i okna dawek, ustawiane przy każdym leku." + "modules": { + "title": "Moduły", + "saved": "Zapisano.", + "error": "Nie udało się zapisać. Spróbuj ponownie.", + "toggleable": { + "title": "Co śledzisz", + "description": "Obszary opcjonalne. Wyłączone moduły znikają z nawigacji, kafelków i analiz — Twoje dane pozostają nienaruszone." }, - "vorsorge": { - "title": "Profilaktyka", - "description": "Cykliczne badania i pomiary wraz z miejscem, gdzie się udać." + "core": { + "title": "Zawsze aktywne", + "description": "Podstawy HealthLog. Tych obszarów nie można wyłączyć.", + "note": "Obszar podstawowy — zawsze aktywny" }, - "mood": { - "title": "Zapis nastroju", - "description": "Codzienne przypomnienie, by zapisać samopoczucie." - }, - "lowStock": { - "title": "Zapas na wyczerpaniu", - "description": "Otrzymaj ostrzeżenie, zanim lek się skończy." - }, - "coach": { - "title": "Podpowiedź trenera", - "description": "Proaktywne wskazówki od trenera AI." - }, - "channels": { - "title": "Kanały powiadomień", - "description": "APNs, Telegram, ntfy, Web Push, Webhook i e-mail." - } + "description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu.", + "manageIn": "Zarządzaj w {section}", + "operatorDisabled": "Wyłączone na całym serwerze" + }, + "gesundheitsakte": { + "title": "Dokumentacja zdrowotna", + "description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP." + }, + "coach": { + "title": "Coach", + "description": "Włącz Coacha, ton i zakres, zapisane wspomnienia." } }, "profile": "Profil", @@ -4997,6 +5022,12 @@ "description": "Ikona ? obok linii delty Health-Score. Cyfra delty pozostaje widoczna niezależnie." } }, + "modules": { + "title": "Dostępność modułów", + "description": "Wyłącz moduł dla całego serwera. Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć.", + "disabledHint": "Wyłączone na całym serwerze", + "saveError": "Nie udało się zaktualizować dostępności modułów." + }, "bugReportRepo": "Repozytorium", "bugReportRepoPlaceholder": "owner/repo", "bugReportToken": "Token zgłoszeń", @@ -6423,6 +6454,8 @@ "addButton": "Dodaj przypomnienie", "markDone": "Gotowe", "delete": "Usuń", + "deleteConfirmTitle": "Usunąć przypomnienie?", + "deleteConfirmDescription": "To przypomnienie profilaktyczne zostanie trwale usunięte.", "disabledBadge": "Wył.", "nextDue": { "none": "Niezaplanowane", @@ -6479,6 +6512,8 @@ "referenceLabel": "Norma", "readingsCount": "{count} wyników", "deleteLatest": "Usuń ostatni wynik", + "deleteConfirmTitle": "Usunąć wynik laboratoryjny?", + "deleteConfirmDescription": "Ten wynik laboratoryjny zostanie trwale usunięty.", "deletedToast": "Wynik usunięty", "deleteError": "Nie udało się usunąć wyniku", "loadError": "Nie udało się wczytać wyników", @@ -6510,5 +6545,67 @@ "savedToast": "Wynik zapisany", "saveError": "Nie udało się zapisać wyniku" } + }, + "modules": { + "cycle": { + "label": "Cykl", + "description": "Śledź miesiączkę, fazy i prognozy." + }, + "mood": { + "label": "Nastrój", + "description": "Zapisuj codzienny nastrój i tagi." + }, + "sleep": { + "label": "Sen", + "description": "Długość, fazy i oś czasu snu." + }, + "glucose": { + "label": "Glukoza", + "description": "Odczyty glukozy i trendy." + }, + "workouts": { + "label": "Treningi", + "description": "Sesje treningowe i aktywność." + }, + "recovery": { + "label": "Regeneracja", + "description": "Wskaźniki regeneracji i obciążenia z Twoich urządzeń." + }, + "labs": { + "label": "Wyniki badań", + "description": "Zapisuj wyniki badań laboratoryjnych i ich historię." + }, + "achievements": { + "label": "Osiągnięcia", + "description": "Odznaki i serie dla Twojej rutyny." + }, + "coach": { + "label": "Coach", + "description": "Czat AI oparty na Twoich danych." + }, + "insights": { + "label": "Analizy", + "description": "Analizy AI, briefing i korelacje." + }, + "doctorReport": { + "label": "Raport dla lekarza", + "description": "Eksport PDF i FHIR dla Twojej przychodni." + }, + "weight": { + "label": "Waga", + "description": "Waga i skład ciała." + }, + "bloodPressure": { + "label": "Ciśnienie krwi", + "description": "Skurczowe i rozkurczowe." + }, + "pulse": { + "label": "Tętno", + "description": "Tętno spoczynkowe i rytm serca." + }, + "medications": { + "label": "Leki", + "description": "Schematy, przyjęcia i zgodność." + } } } diff --git a/next.config.ts b/next.config.ts index 4cef1bf1c..b4f889925 100644 --- a/next.config.ts +++ b/next.config.ts @@ -98,19 +98,49 @@ const nextConfig: NextConfig = { ["stimmung", "mood"], ["medikamente", "medications"], ]; - return insightsSlugRenames.flatMap(([from, to]) => [ + return [ + ...insightsSlugRenames.flatMap(([from, to]) => [ + { + source: `/insights/${from}`, + destination: `/insights/${to}`, + permanent: true, + }, + // Preserve any nested path (e.g. a future `/insights/sleep/2026-05-31`). + { + source: `/insights/${from}/:path*`, + destination: `/insights/${to}/:path*`, + permanent: true, + }, + ]), + // v1.18.0 — the Coach moved out of the Insights surface to a + // standalone top-level page. The legacy `/insights/coach` URL + // 301-redirects to `/coach` so bookmarks, the PWA's cached + // navigation, and any pre-update push deep-link keep resolving. { - source: `/insights/${from}`, - destination: `/insights/${to}`, + source: "/insights/coach", + destination: "/coach", permanent: true, }, - // Preserve any nested path (e.g. a future `/insights/sleep/2026-05-31`). + // v1.18.0 (S3) — Sources folded into Settings → Integrations as the + // "Sources" sub-tab; the standalone `/settings/sources` page is gone. + // 301-redirect so bookmarks, the PWA's cached navigation, and the old + // cross-links keep resolving. { - source: `/insights/${from}/:path*`, - destination: `/insights/${to}/:path*`, + source: "/settings/sources", + destination: "/settings/integrations", permanent: true, }, - ]); + // v1.18.0 (S4) — the standalone "Erinnerungen" hub at + // `/settings/reminders` was a link-only page that duplicated the + // canonical editors. Reminder TYPES now live in Notifications, each + // gated on its module. 301-redirect so bookmarks, the PWA's cached + // navigation, and the old cross-links keep resolving. + { + source: "/settings/reminders", + destination: "/settings/notifications", + permanent: true, + }, + ]; }, async headers() { return [ diff --git a/package.json b/package.json index 844133e9f..2058f15ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.17.1", + "version": "1.18.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0167_module_enable_disable/migration.sql b/prisma/migrations/0167_module_enable_disable/migration.sql new file mode 100644 index 000000000..0a5a69144 --- /dev/null +++ b/prisma/migrations/0167_module_enable_disable/migration.sql @@ -0,0 +1,38 @@ +-- v1.18.0 — module enable/disable foundation. +-- +-- A single nullable JSONB blob on `users` carrying a DISABLED allowlist +-- for the optional ("secondary") domains (mood, sleep, glucose, workouts, +-- recovery, labs, achievements, insights, doctor-report, …). The contract +-- is default-on: +-- +-- * NULL column → every module enabled +-- * empty object `{}` → every module enabled +-- * key absent → that module enabled +-- * key present & `false` → that module DISABLED +-- * key present & `true` → enabled (redundant-but-allowed) +-- +-- No backfill — every existing user keeps every module on. The +-- application resolver (`src/lib/modules/gate.ts`) tolerates a missing +-- column / null row and reads all-on, so a partial deploy where the +-- schema lands ahead of (or behind) the app code keeps working. +-- +-- CORE domains (weight, blood pressure, pulse, medications) are never +-- written here and the write endpoint refuses to disable them, so the +-- measurement engine + meds can never be turned off via this column. +-- `cycle` and `coach` keep their existing source of truth +-- (`CycleProfile.cycle_tracking_enabled` / `users.disable_coach` + +-- the operator assistant master flag) — this column does not own them. +-- +-- No additional index — reads are per-user (`WHERE id = $1`), matching +-- the convention set by `coach_prefs_json`, `source_priority_json`, +-- `doctor_report_prefs_json`, and `notification_prefs` on the same table +-- (no GIN index on any of them). +-- +-- Idempotent guard (`IF NOT EXISTS`) so reruns are safe on prod. +-- +-- Reversibility: down migration is +-- ALTER TABLE "users" DROP COLUMN IF EXISTS "module_preferences_json"; +-- A roll-back loses per-user module opt-outs (every user falls back to +-- all-on), which is the safe default — no data is destroyed. +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "module_preferences_json" JSONB; diff --git a/prisma/migrations/0168_operator_module_availability/migration.sql b/prisma/migrations/0168_operator_module_availability/migration.sql new file mode 100644 index 000000000..7b0de2c0d --- /dev/null +++ b/prisma/migrations/0168_operator_module_availability/migration.sql @@ -0,0 +1,39 @@ +-- v1.18.0 — operator-level server-wide module availability. +-- +-- A single nullable JSONB blob on `app_settings` carrying a DISABLED +-- allowlist for the toggleable ("secondary") modules — the second layer of +-- the two-layer module model. This sits ABOVE the per-user +-- `users.module_preferences_json`: an operator-disabled module is off for +-- EVERY account regardless of that account's personal preference, exactly +-- like the coach master flag (`assistant_coach_enabled`) sits above +-- `users.disable_coach`. +-- +-- The contract mirrors the per-user column (default-on): +-- +-- * NULL column → every module available +-- * empty object `{}` → every module available +-- * key absent → that module available +-- * key present & `false` → that module DISABLED server-wide +-- * key present & `true` → available (redundant-but-allowed) +-- +-- No backfill — every existing instance keeps every module available. The +-- application resolver (`src/lib/modules/gate.ts`) tolerates a missing +-- column / null row and reads all-available, so a partial deploy where the +-- schema lands ahead of (or behind) the app code keeps working. +-- +-- CORE domains (weight, blood pressure, pulse, medications) are never +-- written here and the admin write endpoint refuses them, so the +-- measurement engine + meds can never be turned off server-wide either. +-- +-- No additional index — the single `app_settings` singleton row is read by +-- primary key, matching the convention of every other JSON column on the +-- table. +-- +-- Idempotent guard (`IF NOT EXISTS`) so reruns are safe on prod. +-- +-- Reversibility: down migration is +-- ALTER TABLE "app_settings" DROP COLUMN IF EXISTS "module_availability_json"; +-- A roll-back loses operator-level opt-outs (every module falls back to +-- available), which is the safe default — no data is destroyed. +ALTER TABLE "app_settings" + ADD COLUMN IF NOT EXISTS "module_availability_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbf1b8bf7..6c07d244d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -401,6 +401,35 @@ model User { // `useAuth()` hook. disableCoach Boolean @default(false) @map("disable_coach") + // v1.18.0 — module enable/disable foundation. + // + // A DISABLED allowlist for the optional ("secondary") domains. The + // contract is default-on: a NULL column, an empty object, or a key + // simply absent all mean "module enabled". A key present and set to + // `false` means that module is OFF; `true` is redundant-but-allowed. + // No backfill — every existing user reads as all-on. + // + // Shape (string keys ⇒ boolean): + // { "mood": false, "glucose": false, ... } + // + // The canonical key set + which keys are toggleable lives in + // `src/lib/modules/registry.ts`; the resolver + 403 guard live in + // `src/lib/modules/gate.ts`. CORE domains (weight, blood pressure, + // pulse, medications — the measurement engine + meds) are NEVER + // present here and the gate refuses to disable them, so a crafted + // `{ "weight": false }` blob cannot turn the core engine off. + // + // Two modules do NOT resolve from this column to avoid a double + // source of truth: `cycle` delegates to `CycleProfile`/`isCycleEnabled` + // and `coach` delegates to `User.disableCoach` + the operator-level + // assistant master flag. The column may still carry a `cycle`/`coach` + // key for forward-compat but the gate ignores it for those two. + // + // Persisted via `PATCH /api/auth/me/modules` with field-by-field + // merge semantics (no mass assignment); surfaced resolved on + // `GET /api/auth/me` as a `modules: { : boolean }` map. + modulePreferencesJson Json? @map("module_preferences_json") + // v1.5.5 — self-hosted avatar storage. // // Replaces the Gravatar third-party leak (the old `getGravatarUrl` @@ -2844,6 +2873,18 @@ model AppSettings { assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled") assistantHealthScoreExplainerEnabled Boolean @default(true) @map("assistant_health_score_explainer_enabled") + // v1.18.0 — operator-level server-wide module availability. A DISABLED + // allowlist mirroring the per-user `User.modulePreferencesJson`: absent / + // empty / `true` ⇒ available, an explicit `false` ⇒ disabled for EVERY + // account regardless of personal preference. This is the second layer of + // the two-layer module model (operator AND user), exactly like the coach + // master flag (`assistant_coach_enabled`) sits above `User.disableCoach`. + // NULL = every module available (no admin action needed on upgrade). Core + // domains (weight, BP, pulse, medications) are never written here and the + // admin write endpoint refuses them, so the measurement engine + meds can + // never be disabled. Resolved in `src/lib/modules/gate.ts`. + moduleAvailabilityJson Json? @map("module_availability_json") + @@map("app_settings") } diff --git a/public/sw.js b/public/sw.js index 2a9c762de..463bc62e2 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.17.1"; + /* @sw-version-fallback */ "v1.18.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150; diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index fab01da16..513927772 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -260,16 +260,25 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { } export default function AchievementsPage() { - const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); + // v1.18.0 — the achievements module gate. When the account has the + // module turned off the whole page disappears: an empty state stands in + // for the badge grid and the data query never fires (the API would 403 + // anyway). Default-on: an absent key reads as enabled, so the page only + // hides on an explicit `false`. + const achievementsEnabled = user?.modules?.achievements !== false; + // v1.4.34 IW-F-Perf — mother page rides the shared cache slot // alongside `` and // `` so the three consumers never trigger // more than one network call on a cold dashboard mount. - const { data, isLoading } = useAchievementsQuery(); + const { data, isLoading } = useAchievementsQuery({ + enabled: isAuthenticated && achievementsEnabled, + }); - if (authLoading || isLoading) { + if (authLoading || (achievementsEnabled && isLoading)) { return (
@@ -277,6 +286,14 @@ export default function AchievementsPage() { ); } + // v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry + // is hidden by the same gate, so a direct hit on /achievements renders + // nothing rather than an orphaned shell. No new i18n string is minted + // (the disabled state has no copy of its own). + if (isAuthenticated && !achievementsEnabled) { + return null; + } + if (!isAuthenticated) { return (
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 4b49212d2..d1e4db387 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -29,6 +29,7 @@ import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section" import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section"; import { LoginOverviewSection } from "@/components/admin/login-overview-section"; +import { ModuleAvailabilitySection } from "@/components/admin/module-availability-section"; import { RemindersSection } from "@/components/admin/reminders-section"; import { ServicesSection } from "@/components/admin/services-section"; import { SystemStatusSection } from "@/components/admin/system-status-section"; @@ -105,6 +106,7 @@ export function AdminSectionRenderer({ subtitle={t("admin.section.assistant.subtitle")} > + ); diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts new file mode 100644 index 000000000..91447908d --- /dev/null +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -0,0 +1,363 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; + +/** + * v1.18.0 — module API route gate inventory. + * + * The v1.18.0 module retrofit can hide a toggleable module across the + * nav, dashboard tiles, Insights pills, reminder jobs, doctor-report + * sections, and the AI surfaces. The ONE server-side enforcement point + * is `@/lib/modules/gate` — `requireModuleEnabled(userId, key)` in API + * routes, `isModuleEnabled` / `resolveModuleMap` in builders + jobs + + * components. Without a discovery test, a future contributor who lands a + * new per-domain route for a toggleable module (a new mood-analysis read, + * a new sleep view, a new glucose panel) would have to remember the gate + * by hand. A silent miss leaks the surface over a Bearer token even when + * the account — or the operator — turned the module off. + * + * This test walks every per-domain route tree that serves a TOGGLEABLE + * module and pins each `route.ts` into exactly one of these buckets: + * + * 1. MODULE-GATED — the file calls `requireModuleEnabled(...)` for the + * right key (mood / sleep / glucose / workouts / recovery / labs / + * achievements). This is the direct gate. + * + * 2. DELEGATED — the module's enabled-state is owned elsewhere and the + * route gates on that single source of truth instead of re-deriving + * it (no double source of truth, mirroring the registry's + * `delegatesTo`): + * - cycle → `requireCycleEnabled(...)` (the cycle gate the + * `cycle` ModuleKey delegates to). + * - coach → `requireAssistantSurface("coach")` (the assistant + * master flag + per-user opt-out the `coach` ModuleKey + * delegates to). Covered in depth by the sibling + * `coach-route-gate-inventory.test.ts`; listed here so + * coach-bearing routes are not flagged as ungated. + * + * 3. BUILDER-GATED — the route delegates its whole payload to an + * aggregator that resolves `resolveModuleMap(userId)` once and + * excludes disabled-module sections/resources from the build + * (`collectDoctorReportData`, `loadFhirContext`). The exclusion is + * structural at the builder, so the route carries no per-key call. + * + * 4. EXEMPT — an explicit, COMMENTED allowlist of routes that serve a + * toggleable domain but are deliberately NOT gated, each with the + * reason at the entry. The two reasons in play: + * - DATA LAYER: raw CRUD over the domain's own rows. Writing or + * reading a disabled module's data is harmless — the module gate + * governs whether the domain SURFACES (nav, tiles, analysis, + * reports), not whether the row store accepts writes. Disabling + * a module must not wedge an importer / sync / the user's ability + * to clean up data, and re-enabling must find the data intact. + * - INFRA / UI-ONLY: settings, availability probes, and the static + * FHIR CapabilityStatement carry no module data. + * + * Anything that doesn't fit one of the four buckets is an orphan and + * fails the test BY NAME so the fix is one search-and-add: gate the route + * or move it onto the EXEMPT allowlist with a documented reason. + */ + +/** + * The per-domain route trees that serve a toggleable module. Each entry + * is a directory under `src/app/api`; every `route.ts` beneath it is + * enumerated and classified. A domain that is owned entirely by one + * builder (doctor-report / FHIR) or one delegated gate (cycle) is still + * walked so a NEW route in the tree must justify itself. + */ +const MODULE_ROUTE_TREES: ReadonlyArray = [ + "src/app/api/mood", + "src/app/api/mood-entries", + "src/app/api/sleep", + "src/app/api/workouts", + "src/app/api/labs", + "src/app/api/cycle", + "src/app/api/gamification", + // v1.18.0 B3 — the legacy `/api/doctor-report` tree (JSON + server-PDF + + // availability probe) was orphaned dead code (no production caller) and + // removed. The live doctor-report / FHIR surface is `/api/export/health-record`, + // which gates on the `doctorReport` module directly AND through the + // `collectDoctorReportData` builder. + "src/app/api/fhir", + // v1.18.0 (B2) — the AI-narrative insights tree. Every status / cards / + // correlations / derived / narrative / pregenerate / rhythm-events route + // gates on the `insights` module; the Coach sub-tree delegates to the + // coach assistant surface; the AI-settings / tile-layout / therapy-timeline + // infra-and-config routes are EXEMPT below. Walking the tree means a NEW + // ungated insights AI route fails this test BY NAME rather than leaking the + // surface over a Bearer token when the account turned insights off. + "src/app/api/insights", +]; + +/** + * EXEMPT — routes that serve a toggleable domain but are deliberately + * ungated. Each carries its reason inline per the bucket-4 contract. + */ +const EXEMPT_ROUTES: ReadonlyArray = [ + // ── DATA LAYER (mood) ───────────────────────────────────────────── + // Raw MoodEntry CRUD. The module gate governs whether mood SURFACES + // (nav, dashboard tile, analysis, doctor report), not whether the row + // store accepts writes. An importer / the moodLog sync / the user's + // ability to delete or restore entries must keep working while the + // module is off, and re-enabling must find the rows intact. + "src/app/api/mood-entries/route.ts", + "src/app/api/mood-entries/[id]/route.ts", + "src/app/api/mood-entries/bulk/route.ts", + "src/app/api/mood-entries/bulk-delete/route.ts", + "src/app/api/mood-entries/restore/route.ts", + // Mood tag taxonomy CRUD — the user's tag library + per-tag layout. + // Pure configuration over the mood vocabulary, not analysis prose; + // editing the taxonomy while the module is off must not break. + "src/app/api/mood/tags/route.ts", + "src/app/api/mood/tags/layout/route.ts", + "src/app/api/mood/tags/custom/route.ts", + "src/app/api/mood/tags/custom/[key]/route.ts", + "src/app/api/mood/tags/groups/route.ts", + "src/app/api/mood/tags/groups/[key]/route.ts", + "src/app/api/mood/tags/[key]/hidden/route.ts", + // ── DATA LAYER (workouts / labs) ────────────────────────────────── + // The workout READ surfaces (`GET /api/workouts`, `GET /api/workouts/{id}`) + // are now module-gated — they back the hidden Insights workouts surface, so + // a disabled account must not read them even over a Bearer token. Only the + // iOS batch INGEST stays exempt: synced workouts must keep landing in the + // row store while the surface is hidden, so re-enabling reveals a complete + // history rather than a gap. LabResult CRUD follows the same data-layer + // reasoning (the labs module gates the surfaces, not the row store). + "src/app/api/workouts/batch/route.ts", + "src/app/api/labs/route.ts", + "src/app/api/labs/[id]/route.ts", + // ── INFRA / UI-ONLY ─────────────────────────────────────────────── + // Static FHIR CapabilityStatement — server metadata, no user data. + "src/app/api/fhir/metadata/route.ts", + // ── INFRA / CONFIG (insights) ───────────────────────────────────── + // v1.18.0 (B2) — the insights tree's non-narrative routes. The + // `insights` module gates the AI-narrative SURFACES (status cards, + // correlations, derived scores, period narrative, the rhythm-event + // timeline); these six carry no narrative payload, so gating them would + // only break configuration / settings reads while the module is off. + // + // AI provider + privacy settings and the read-only chain summary — pure + // configuration of the assistant, surfaced under Settings → AI, not an + // insights surface. Editing the AI config while insights is off must work + // (e.g. to set up a provider before re-enabling the module). + "src/app/api/insights/settings/route.ts", + "src/app/api/insights/provider-chain/route.ts", + // Insights tile-order + visibility layout (GET/PUT/DELETE) — the user's + // own persisted preference blob, the insights peer of + // `/api/dashboard/widgets`. Pure UI configuration, no module data; the + // /insights page itself is nav-gated on the module. + "src/app/api/insights/layout/route.ts", + // Insight feedback write (👍/👎 on a generated card) — a feedback row, + // not an insights READ. Harmless while the module is off and never + // surfaces module data. + "src/app/api/insights/feedback/route.ts", + // Target-range reference values (BMI / BP / sleep / steps classifiers + + // compliance context) — deterministic threshold config consumed across + // surfaces, not an AI-narrative insights read. + "src/app/api/insights/targets/route.ts", + // GLP-1 therapy-timeline aggregator backing the /insights/medications + // component — a MEDICATIONS-domain data merge (dose / injection / + // inventory / side-effect events), and medications is a core, always-on + // domain with no module gate. It is not an insights-module surface. + "src/app/api/insights/glp1-timeline/route.ts", +]; + +const MODULE_GATE_NEEDLE = "requireModuleEnabled("; +const CYCLE_GATE_NEEDLE = "requireCycleEnabled("; +const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; +// Builder aggregators that resolve `resolveModuleMap` once and exclude +// disabled-module sections/resources at the build boundary. +const BUILDER_GATE_NEEDLES: ReadonlyArray = [ + "collectDoctorReportData", + "loadFhirContext", +]; + +/** + * True when the file contains the needle on a line that is NOT a pure + * comment. A docstring that merely MENTIONS the gate must not satisfy the + * presence check — otherwise deleting the real call but leaving the + * comment would slip through. + */ +function fileHasCall(text: string, needle: string): boolean { + return text.split("\n").some((line) => { + if (!line.includes(needle)) return false; + const trimmed = line.trim(); + if (trimmed.startsWith("//")) return false; + if (trimmed.startsWith("*")) return false; + return true; + }); +} + +/** + * Extract the module key from a `requireModuleEnabled(user.id, "")` + * call. Returns the literal key, or null when the key is computed (the + * parameterised insights routes resolve it from a metric map — those gate + * dynamically and are accepted as gated without a literal-key assertion). + */ +function extractModuleKeys(text: string): { hasLiteral: boolean; keys: Set } { + const keys = new Set(); + let hasLiteral = false; + const re = /requireModuleEnabled\([^,]+,\s*"([a-zA-Z]+)"\s*\)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + hasLiteral = true; + keys.add(m[1]); + } + return { hasLiteral, keys }; +} + +const repoRoot = resolve(__dirname, "..", "..", "..", ".."); + +/** Walk one route tree, returning POSIX paths relative to the repo root. */ +function findRouteFiles(treeRel: string): string[] { + const root = resolve(repoRoot, treeRel); + const hits: string[] = []; + + function walk(dir: string): void { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + if (entry === "__tests__" || entry === "node_modules") continue; + walk(full); + continue; + } + if (entry !== "route.ts") continue; + hits.push(relative(repoRoot, full).split(/[\\/]/).join("/")); + } + } + + walk(root); + return hits; +} + +function findAllModuleRouteFiles(): string[] { + return MODULE_ROUTE_TREES.flatMap(findRouteFiles).sort(); +} + +describe("module API route gate inventory", () => { + it("the module registry covers the keys this inventory reasons about", () => { + // Guard against a registry key being added without the inventory + // gaining an opinion about its routes. Every gated/delegated key the + // test pins must be a real ModuleKey. + const known = new Set(MODULE_KEYS); + for (const key of [ + "mood", + "sleep", + "glucose", + "workouts", + "recovery", + "labs", + "achievements", + "cycle", + "coach", + "doctorReport", + "insights", + ] as const) { + expect(known.has(key), `unknown module key in inventory: ${key}`).toBe( + true, + ); + } + }); + + it("every toggleable-module route is gated, delegated, builder-gated, or explicitly exempt", () => { + const routes = findAllModuleRouteFiles(); + expect(routes.length).toBeGreaterThan(0); + + const exempt = new Set(EXEMPT_ROUTES); + const orphans: Array<{ path: string; reason: string }> = []; + + for (const path of routes) { + const text = readFileSync(resolve(repoRoot, path), "utf8"); + + if (fileHasCall(text, MODULE_GATE_NEEDLE)) continue; + if (fileHasCall(text, CYCLE_GATE_NEEDLE)) continue; + if (fileHasCall(text, COACH_GATE_NEEDLE)) continue; + if (BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n))) continue; + + if (exempt.has(path)) continue; + + orphans.push({ + path, + reason: + 'no module gate found — add requireModuleEnabled(""), a delegated gate, ' + + "a module-aware builder, or move the route onto EXEMPT_ROUTES with a documented reason", + }); + } + + expect( + orphans, + [ + "Module API route gate inventory found unaccounted-for handler(s):", + ...orphans.map((o) => ` - ${o.path}: ${o.reason}`), + ].join("\n"), + ).toEqual([]); + }); + + it("directly module-gated routes pin a real module key", () => { + const routes = findAllModuleRouteFiles(); + const known = new Set(MODULE_KEYS); + const bad: Array<{ path: string; key: string }> = []; + + for (const path of routes) { + const text = readFileSync(resolve(repoRoot, path), "utf8"); + if (!fileHasCall(text, MODULE_GATE_NEEDLE)) continue; + const { keys } = extractModuleKeys(text); + // Parameterised routes (computed key) carry no literal — accepted. + for (const key of keys) { + if (!known.has(key)) bad.push({ path, key }); + } + } + + expect( + bad, + [ + "requireModuleEnabled() called with an unknown module key:", + ...bad.map((b) => ` - ${b.path}: "${b.key}"`), + ].join("\n"), + ).toEqual([]); + }); + + it("EXEMPT_ROUTES does not reference deleted route files", () => { + const known = new Set(findAllModuleRouteFiles()); + const stale = EXEMPT_ROUTES.filter((p) => !known.has(p)); + + expect( + stale, + [ + "EXEMPT_ROUTES points to files that no longer exist —", + "delete the stale entries from", + "`src/app/api/__tests__/module-route-gate-inventory.test.ts`:", + ...stale.map((p) => ` - ${p}`), + ].join("\n"), + ).toEqual([]); + }); + + it("EXEMPT_ROUTES does not hide a route that actually carries a gate", () => { + // A route that gained a real gate should be removed from the exempt + // list so the list stays an honest record of the ungated surfaces. + const stillGated: string[] = []; + for (const path of EXEMPT_ROUTES) { + const text = readFileSync(resolve(repoRoot, path), "utf8"); + if ( + fileHasCall(text, MODULE_GATE_NEEDLE) || + fileHasCall(text, CYCLE_GATE_NEEDLE) || + fileHasCall(text, COACH_GATE_NEEDLE) || + BUILDER_GATE_NEEDLES.some((n) => fileHasCall(text, n)) + ) { + stillGated.push(path); + } + } + + expect( + stillGated, + [ + "These EXEMPT_ROUTES now carry a gate — drop them from the exempt list:", + ...stillGated.map((p) => ` - ${p}`), + ].join("\n"), + ).toEqual([]); + }); +}); diff --git a/src/app/api/admin/settings/module-availability/__tests__/route.test.ts b/src/app/api/admin/settings/module-availability/__tests__/route.test.ts new file mode 100644 index 000000000..2532ed669 --- /dev/null +++ b/src/app/api/admin/settings/module-availability/__tests__/route.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const ADMIN_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "admin-1", username: "testuser", role: "ADMIN" as const }, +}; +const USER_OK = { + session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "bob", role: "USER" as const }, +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/admin/settings/module-availability", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/module-availability"), + ); + expect(res.status).toBe(401); + }); + + it("returns 403 when the caller is not an admin (cookie-only boundary)", async () => { + vi.mocked(getSession).mockResolvedValue(USER_OK as never); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/module-availability"), + ); + expect(res.status).toBe(403); + }); + + it("returns an all-available map for a fresh install (null column)", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/module-availability"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { availability: Record }; + }; + expect(body.data.availability.mood).toBe(true); + expect(body.data.availability.coach).toBe(true); + expect(body.data.availability.doctorReport).toBe(true); + }); + + it("reflects a persisted operator-disabled module", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + moduleAvailabilityJson: { mood: false }, + } as never); + const res = await (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/settings/module-availability"), + ); + const body = (await res.json()) as { + data: { availability: Record }; + }; + expect(body.data.availability.mood).toBe(false); + expect(body.data.availability.sleep).toBe(true); + }); +}); + +describe("PATCH /api/admin/settings/module-availability", () => { + function patchReq(payload: object) { + return new NextRequest( + "http://localhost/api/admin/settings/module-availability", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + } + + it("returns 403 to a non-admin caller", async () => { + vi.mocked(getSession).mockResolvedValue(USER_OK as never); + const res = await PATCH(patchReq({ mood: false })); + expect(res.status).toBe(403); + }); + + it("disables a module server-wide and echoes the resolved map", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + moduleAvailabilityJson: { mood: false }, + } as never); + + const res = await PATCH(patchReq({ mood: false })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { availability: Record }; + }; + expect(body.data.availability.mood).toBe(false); + expect(body.data.availability.glucose).toBe(true); + }); + + it("merges onto the existing blob (partial patch keeps other keys)", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + moduleAvailabilityJson: { sleep: false }, + } as never); + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + moduleAvailabilityJson: { sleep: false, mood: false }, + } as never); + + const res = await PATCH(patchReq({ mood: false })); + expect(res.status).toBe(200); + // The upsert must carry BOTH the pre-existing sleep:false and the new + // mood:false — the partial patch never drops untouched keys. + const upsertArg = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0]; + expect( + (upsertArg.update as { moduleAvailabilityJson: Record }) + .moduleAvailabilityJson, + ).toEqual({ sleep: false, mood: false }); + }); + + it("rejects an empty body", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PATCH(patchReq({})); + expect(res.status).toBe(422); + }); + + it("rejects a core domain key strictly (weight can never be disabled)", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PATCH(patchReq({ weight: false })); + expect(res.status).toBe(422); + }); + + it("rejects an unknown module key strictly", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never); + const res = await PATCH(patchReq({ potato: false })); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/admin/settings/module-availability/route.ts b/src/app/api/admin/settings/module-availability/route.ts new file mode 100644 index 000000000..da9dea71a --- /dev/null +++ b/src/app/api/admin/settings/module-availability/route.ts @@ -0,0 +1,133 @@ +/** + * v1.18.0 — `GET` / `PATCH /api/admin/settings/module-availability` — + * operator-side surface for the server-wide module availability matrix. + * + * The SECOND layer of the two-layer module model. A module the operator + * turns off here is off for EVERY account regardless of that account's + * personal preference — mirroring the coach master flag + * (`assistant_coach_enabled`) sitting above the per-user + * `User.disableCoach`. The gate (`src/lib/modules/gate.ts`) AND-s this + * operator availability with the per-user opt-out, and `GET /api/auth/me` + * already projects the resolved (operator AND user) map, so web + iOS need + * no extra logic. + * + * Dedicated endpoint (separate from the generic `/api/admin/settings`) + * so the admin panel can wire an optimistic matrix against a focused + * request/response shape and the audit trail carries a single + * `admin.settings.module-availability.update` action. + * + * Request shape — a partial DISABLED-allowlist patch keyed by toggleable + * module key; every field optional; `true` ⇒ available, `false` ⇒ disabled + * server-wide: + * + * { "mood": false, "glucose": true } + * + * Core domains (weight, BP, pulse, medications) are NOT module keys; the + * `.strict()` schema rejects them, so the measurement engine + meds can + * never be disabled through this route. + * + * `requireAdmin()` gates the route — cookie-only by construction; a Bearer + * token (even `["*"]` scope) cannot reach it. + */ +import type { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { + mergeAvailabilityPatch, + resolveOperatorAvailability, +} from "@/lib/modules/operator-availability"; +import { MODULE_KEYS } from "@/lib/modules/registry"; + +export const dynamic = "force-dynamic"; + +/** + * Strict per-module boolean patch. Building the shape from `MODULE_KEYS` + * keeps the schema exhaustive and rejects any non-module key (including + * the four core domains) at the validation boundary. + */ +const moduleAvailabilitySchema = z + .object( + Object.fromEntries( + MODULE_KEYS.map((key) => [key, z.boolean().optional()]), + ) as Record<(typeof MODULE_KEYS)[number], z.ZodOptional>, + ) + .strict(); + +export const GET = apiHandler(async () => { + await requireAdmin(); + annotate({ action: { name: "admin.settings.module-availability.get" } }); + + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moduleAvailabilityJson: true }, + }); + + return apiSuccess({ + availability: resolveOperatorAvailability(settings?.moduleAvailabilityJson), + }); +}); + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.settings.module-availability.update" } }); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = moduleAvailabilitySchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const patch: Record = {}; + for (const [key, value] of Object.entries(parsed.data)) { + if (typeof value === "boolean") { + patch[key] = value; + } + } + + if (Object.keys(patch).length === 0) { + return apiError("No valid fields", 422); + } + + // Merge onto the existing blob so a partial PATCH leaves untouched keys + // intact; the merge drops any non-module key defensively. + const existing = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moduleAvailabilityJson: true }, + }); + const merged = mergeAvailabilityPatch( + existing?.moduleAvailabilityJson, + patch, + ); + + const settings = await prisma.appSettings.upsert({ + where: { id: "singleton" }, + update: { moduleAvailabilityJson: merged }, + create: { id: "singleton", moduleAvailabilityJson: merged }, + select: { moduleAvailabilityJson: true }, + }); + + await auditLog("admin.settings.module-availability.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: patch, + }); + + return apiSuccess({ + availability: resolveOperatorAvailability(settings.moduleAvailabilityJson), + }); +}); diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 22d4f3a9e..7d51e212b 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -78,10 +78,28 @@ vi.mock("next/headers", () => ({ })), })); +// v1.18.0 — the health-score fast path resolves the per-user module map to +// drop disabled-module pillars; stub the gate so analytics tests don't need +// the real DB-backed resolver (default: every module enabled). +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(), + isModuleEnabled: vi.fn(), + requireModuleEnabled: vi.fn(), + }; +}); + import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { + resolveModuleMap, + isModuleEnabled, + requireModuleEnabled, +} from "@/lib/modules/gate"; const SESSION_USER = { id: "user-1", @@ -105,6 +123,11 @@ beforeEach(() => { // on the prior test's cached response). __resetAllCachesForTests(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(resolveModuleMap).mockResolvedValue({} as never); + vi.mocked(isModuleEnabled).mockResolvedValue(true as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: true, + } as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, @@ -340,6 +363,66 @@ describe("GET /api/analytics", () => { expect(body.data.lastSeenByType.PULSE).toBeNull(); }); + // v1.18.0 B1 — the default slice serves the glucose-only surfaces + // (per-context summaries + the TIR/GMI/eA1C clinical panel) only when + // the `glucose` module is enabled. A disabled-glucose account nulls + // both fields rather than 403-ing the whole core-metrics payload. + function seedGlucoseRows() { + const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + vi.mocked(prisma.measurement.findMany).mockImplementation( + (async (args: { where?: { type?: string } }) => { + if (args?.where?.type === "BLOOD_GLUCOSE") { + return [ + { value: 95, measuredAt, glucoseContext: "FASTING" }, + { value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" }, + ]; + } + return []; + }) as never, + ); + } + + it("serves glucose clinical + per-context when glucose enabled", async () => { + seedGlucoseRows(); + vi.mocked(isModuleEnabled).mockResolvedValue(true as never); + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + glucoseByContext: Record | null; + glucoseClinical: unknown | null; + }; + }; + expect(body.data.glucoseClinical).not.toBeNull(); + expect(body.data.glucoseByContext).not.toBeNull(); + expect(body.data.glucoseByContext?.FASTING).toBeDefined(); + }); + + it("nulls glucose clinical + per-context when glucose disabled", async () => { + seedGlucoseRows(); + vi.mocked(isModuleEnabled).mockImplementation( + (async (_userId: string, key: string) => key !== "glucose") as never, + ); + const res = await ( + GET as unknown as (...args: never[]) => Promise + )(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + glucoseByContext: Record | null; + glucoseClinical: unknown | null; + summaries: Record; + }; + }; + // Core metrics still served — the route did NOT 403. + expect(body.data.summaries.WEIGHT.count).toBe(0); + // Glucose-only surfaces are nulled. + expect(body.data.glucoseClinical).toBeNull(); + expect(body.data.glucoseByContext).toBeNull(); + }); + // v1.4.49.1 — the 15-way per-type `fetchMeasurementSeriesChunked` // fan-out was removed entirely; the default slice now delegates to // `computeSummariesSlice` which runs three rollup-tier `$queryRaw` diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 628a28b4a..749a7beed 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -17,6 +17,7 @@ import { type BpInTargetEnvelope, } from "@/lib/analytics/bp-in-target-fast-path"; import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; @@ -420,12 +421,27 @@ async function buildAnalyticsResponse(user: AuthedUser) { // same prior-week delta values). The hand-rolled per-surface assembly that // let the two diverge is gone. const bpInputs = buildHealthScoreBpInputs(bpEnvelope, bpEnvelopePriorWeek); + // v1.18.0 R4 — resolve the `mood` module server-side so a disabled-mood + // account drops the mood-stability pillar from the ring instead of + // being silently scored on data hidden everywhere else. The dashboard + // already blanks the mood tile + the coach snapshot unions mood out; + // this closes the matching gap on the Health Score. + const moodEnabled = await isModuleEnabled(user.id, "mood"); + // v1.18.0 B1 — resolve the `glucose` module server-side. The analytics + // route serves core metrics too, so a disabled-glucose account must NOT + // 403 the whole payload; instead the glucose-only surfaces (per-context + // summaries + the TIR/GMI/eA1C clinical panel) are nulled out so they + // never reach a hidden surface. Mirrors the mood-pillar gate above. + const glucoseEnabled = await isModuleEnabled(user.id, "glucose"); + const glucoseByContextOut = glucoseEnabled ? glucoseByContext : null; + const glucoseClinicalOut = glucoseEnabled ? glucoseClinical : null; const healthScore = await computeUserHealthScoreFastPath({ userId: user.id, ...bpInputs, heightCm: user.heightCm ?? null, now: new Date(), coverage, + moodEnabled, }); return { @@ -439,8 +455,8 @@ async function buildAnalyticsResponse(user: AuthedUser) { bpInTargetPctPriorYear, bpInTargetCount90, bpInTargetSpanDays90, - glucoseByContext, - glucoseClinical, + glucoseByContext: glucoseByContextOut, + glucoseClinical: glucoseClinicalOut, correlations, healthScore, sleepStages, diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts new file mode 100644 index 000000000..d7d80ce70 --- /dev/null +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -0,0 +1,307 @@ +/** + * v1.18.0 — PATCH/GET /api/auth/me/modules. + * + * Covers the read projection, the field-by-field merge, core-module + * refusal (strict schema), the cycle/coach delegation reflected in the + * resolved map, and the standard auth / rate-limit / 422 envelope. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + cycleProfile: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); + +// Operator master flag = on by default; coach delegation keys off it. +// Partial mock — api-handler imports `AssistantDisabledError` from here. +vi.mock("@/lib/feature-flags", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAssistantFlags: vi.fn().mockResolvedValue({ coach: true }), + }; +}); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getAssistantFlags } from "@/lib/feature-flags"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +type ModuleMap = Record; + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/modules", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +/** + * Default DB shape: fresh user, no module prefs, no cycle row. The + * `findUnique` mock reads from a mutable holder so a PATCH `update` + * (which writes `modulePreferencesJson`) is reflected by the post-update + * `resolveModuleMap` re-read — mirroring real DB round-trip semantics. + */ +function primeUser(over: { + gender?: string | null; + disableCoach?: boolean; + modulePreferencesJson?: unknown; + cycleTrackingEnabled?: boolean | null; +} = {}) { + const row: { + gender: string | null; + disableCoach: boolean; + modulePreferencesJson: unknown; + } = { + gender: over.gender ?? null, + disableCoach: over.disableCoach ?? false, + modulePreferencesJson: over.modulePreferencesJson ?? null, + }; + vi.mocked(prisma.user.findUnique).mockImplementation( + (() => Promise.resolve({ ...row })) as never, + ); + vi.mocked(prisma.user.update).mockImplementation(((args: { + data?: { modulePreferencesJson?: unknown }; + }) => { + const data = args.data; + if (data && "modulePreferencesJson" in data) { + row.modulePreferencesJson = data.modulePreferencesJson; + } + return Promise.resolve({}); + }) as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue( + over.cycleTrackingEnabled === undefined + ? null + : ({ cycleTrackingEnabled: over.cycleTrackingEnabled } as never), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never); +}); + +describe("GET /api/auth/me/modules", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + expect(res.status).toBe(401); + }); + + it("returns every toggleable module on for a fresh user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.mood).toBe(true); + expect(env.data.modules.sleep).toBe(true); + expect(env.data.modules.insights).toBe(true); + // cycle gender-derived (null gender ⇒ off); coach delegated (on). + expect(env.data.modules.cycle).toBe(false); + expect(env.data.modules.coach).toBe(true); + }); + + it("reflects a disabled module from the persisted allowlist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: { glucose: false } }); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.glucose).toBe(false); + expect(env.data.modules.mood).toBe(true); + }); + + it("reflects coach delegation (operator master flag off)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(getAssistantFlags).mockResolvedValue({ coach: false } as never); + primeUser(); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.coach).toBe(false); + }); +}); + +describe("PATCH /api/auth/me/modules", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: false }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("disables a module and persists the merged allowlist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: null }); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: false }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.mood).toBe(false); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { mood: false } }, + }); + expect(auditLog).toHaveBeenCalledWith( + "user.modules.update", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ changed: ["mood"] }), + }), + ); + }); + + it("merges over the existing allowlist field-by-field (siblings intact)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // user previously disabled glucose; now disabling sleep too. + primeUser({ modulePreferencesJson: { glucose: false } }); + + await (PATCH as (r: Request) => Promise)(mkPatch({ sleep: false })); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { glucose: false, sleep: false } }, + }); + }); + + it("can re-enable a previously disabled module", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: { glucose: false } }); + + await (PATCH as (r: Request) => Promise)(mkPatch({ glucose: true })); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { glucose: true } }, + }); + }); + + it("REFUSES to disable a core module (strict schema 422)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + + for (const core of ["weight", "bloodPressure", "pulse", "medications"]) { + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ [core]: false }), + ); + expect(res.status).toBe(422); + } + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects an unknown module key with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ notAModule: false }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects a non-boolean value with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: "false" }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects malformed JSON with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const req = new Request("http://localhost/api/auth/me/modules", { + method: "PATCH", + body: "{ not valid json", + headers: { "Content-Type": "application/json" }, + }); + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns 429 when the per-user rate-limit fires", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: false }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("drops a previously-corrupted stored blob to clean booleans on merge", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // junk in the stored row must not leak into the persisted merge. + primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } }); + + await (PATCH as (r: Request) => Promise)(mkPatch({ labs: false })); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { sleep: false, labs: false } }, + }); + }); +}); diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts new file mode 100644 index 000000000..c846f4368 --- /dev/null +++ b/src/app/api/auth/me/modules/route.ts @@ -0,0 +1,126 @@ +/** + * v1.18.0 — per-user module enable/disable preferences. + * + * GET /api/auth/me/modules — resolved `{ : boolean }` map + * for every toggleable module (cycle/coach + * reflect their real delegated state). + * PATCH /api/auth/me/modules — body `{ ?: boolean }`. Merges + * the supplied keys into the persisted + * `modulePreferencesJson` DISABLED allowlist + * (field-by-field, no mass assignment) and + * returns the freshly-resolved module map. + * + * The body schema (`modulePrefsPatchSchema`) is `strict()` over the + * directly-owned toggleable key set, so a core-domain key (`weight`, + * `bloodPressure`, `pulse`, `medications`), a delegated key (`cycle`, + * `coach`), or any unknown key is a 422 — the core measurement engine + + * meds can never be disabled here, and a value for a delegated module can + * never land inert in `modulePreferencesJson`. The delegated modules are + * managed at their real control (cycle in Account, coach in Settings → + * Coach); the Modules hub deep-links there rather than offering a dead + * toggle. + * + * `userId` is always narrowed from `requireAuth()`; the body never + * carries it. The semantics are a DISABLED allowlist: a key set to + * `false` disables that module; `true` (or absence) leaves it enabled. + */ +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate"; +import { modulePrefsPatchSchema } from "@/lib/validations/modules"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.modules.get" } }); + + const modules = await resolveModuleMap(user.id); + return apiSuccess({ modules }); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `modules:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "modules.body.invalid_json"); + } + + const parsed = modulePrefsPatchSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.modules.patch.invalid_shape" }, + meta: { issues: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "modules.invalid", + }); + } + + // Merge the supplied keys into the persisted DISABLED allowlist, + // field-by-field. `normalisePrefs` strips any junk from the stored + // row so a previously-corrupted blob can't poison the merge, and the + // strict schema guarantees `parsed.data` carries only toggleable keys + // (core domains were rejected at the 422 above). + const existing = await prisma.user.findUnique({ + where: { id: user.id }, + select: { modulePreferencesJson: true }, + }); + const merged: Record = normalisePrefs( + existing?.modulePreferencesJson, + ); + const changed: string[] = []; + for (const [key, value] of Object.entries(parsed.data)) { + if (value === undefined) continue; + merged[key] = value; + changed.push(key); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: merged }, + }); + + const modules = await resolveModuleMap(user.id); + + await auditLog("user.modules.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { changed }, + }); + + annotate({ + action: { name: "auth.me.modules.patch" }, + meta: { changed }, + }); + + return apiSuccess({ modules }); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 6538c5818..f7fec1a52 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -6,6 +6,10 @@ import { buildAvatarUrl } from "@/lib/avatar"; import { decrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; import { isCycleEnabled } from "@/lib/cycle/gate"; +import { + resolveModuleMap, + getOperatorModuleAvailability, +} from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; @@ -21,12 +25,24 @@ export const GET = apiHandler(async () => { // cycle-tracking gate; no row is forced, a NULL toggle derives from // gender) is a Postgres round-trip. Running them sequentially added // the cookie hop to every /me — and /me sits on every app boot path. - const [, cycleProfile] = await Promise.all([ + const [, cycleProfile, modules, moduleAvailability] = await Promise.all([ setOnboardingPendingCookie(user.onboardingCompletedAt == null), prisma.cycleProfile.findUnique({ where: { userId: user.id }, select: { cycleTrackingEnabled: true }, }), + // v1.18.0 — resolved module enable/disable map for every toggleable + // module. cycle/coach reflect their real delegated state (the cycle + // gate / disableCoach + operator assistant flag); the rest read the + // disabled-allowlist `modulePreferencesJson`. Default-on. Clients + // hide a whole module surface end-to-end when its key is `false`. + resolveModuleMap(user.id), + // v1.18.0 — operator-layer availability map (server-wide kill-switch). + // The resolved `modules` map above already AND-s this in, so it cannot + // distinguish operator-off from user-off. The Modules hub needs that + // distinction to render an operator-disabled module as a read-only + // "disabled server-wide" row instead of a live toggle that no-ops. + getOperatorModuleAvailability(), ]); const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); @@ -85,5 +101,18 @@ export const GET = apiHandler(async () => { // v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS // hides the whole cycle tab when this is false. cycleTrackingEnabled, + // v1.18.0 — module enable/disable map. `{ : boolean }` + // for every toggleable module; `false` means the module is OFF and + // the surface should disappear end-to-end (nav, dashboard, insights, + // …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the + // resolved `disableCoach` + operator master flag, so this map is the + // single thing a client needs to gate every secondary domain. + modules, + // v1.18.0 — operator-layer availability per toggleable module. `false` + // ⇒ the operator turned the module off server-wide (off for every + // account regardless of personal preference). The Modules hub reads + // this to show a "disabled server-wide" read-only row; everywhere else + // the already-AND-ed `modules` map is the single gate to read. + moduleAvailability, }); }); diff --git a/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts new file mode 100644 index 000000000..3a9123095 --- /dev/null +++ b/src/app/api/coach/about-me/__tests__/route-module-gate.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.18.0 — the about-me self-context GET/PUT enforce the two-layer + * Coach module gate (operator availability AND the per-user + * `disableCoach` opt-out) right after auth. A disabled module returns + * the 403 `module.disabled` envelope before any self-context read or + * write; an enabled module proceeds to the normal 200 path. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), +})); + +const { requireModuleEnabled } = vi.hoisted(() => ({ + requireModuleEnabled: vi.fn(), +})); +vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled })); + +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number, meta?: unknown) => ({ + data: null, + error, + status, + meta, + }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + getClientIp: () => "127.0.0.1", + returnAllZodIssues: (_e: unknown, status: number) => ({ + data: null, + error: "validation", + status, + }), + safeJson: async (req: Request) => ({ data: await req.json(), error: null }), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { + userHealthProfile: { + findUnique: vi.fn(async () => ({ updatedAt: new Date(0) })), + upsert: vi.fn(async () => ({ updatedAt: new Date(0) })), + }, + }, +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => Buffer.from(s), +})); +const emptyCtx = { + aboutMe: null, + conditions: null, + allergies: null, + coachFocus: null, +}; +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextForUser: vi.fn(async () => emptyCtx), + getPendingQuestionsForUser: vi.fn(async () => []), + setPendingQuestionsForUser: vi.fn(), +})); +vi.mock("@/lib/ai/coach/self-context-questions", () => ({ + deriveClarifyingQuestions: vi.fn(async () => ({ + questions: [], + source: "none", + })), +})); + +import { GET, PUT } from "../route"; + +type Envelope = { + data: unknown; + error: string | null; + status: number; + meta?: { errorCode?: string; module?: string }; +}; +const get = GET as unknown as () => Promise; +const put = PUT as unknown as (req: Request) => Promise; + +const disabledResponse: Envelope = { + data: null, + error: 'Module "coach" is not enabled', + status: 403, + meta: { errorCode: "module.disabled", module: "coach" }, +}; + +function putReq(): Request { + return new Request("http://localhost/api/coach/about-me", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ aboutMe: "I run daily" }), + }); +} + +describe("about-me module gate", () => { + beforeEach(() => vi.clearAllMocks()); + + it("GET returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + const res = await get(); + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("module.disabled"); + expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); + }); + + it("GET returns 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + const res = await get(); + expect(res.status).toBe(200); + }); + + it("PUT returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + const res = await put(putReq()); + expect(res.status).toBe(403); + expect(res.meta?.module).toBe("coach"); + }); + + it("PUT returns 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + const res = await put(putReq()); + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts index f25792089..6879dae80 100644 --- a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts +++ b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts @@ -43,10 +43,18 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ vi.mock("@/lib/ai/coach/about-me", () => ({ getSelfContextForUser: vi.fn(), })); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); vi.mock("@/lib/api-response", () => ({ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), - apiError: (error: string, status: number) => ({ data: null, error, status }), + apiError: (error: string, status: number, meta?: unknown) => ({ + data: null, + error, + status, + meta, + }), getClientIp: () => "127.0.0.1", returnAllZodIssues: (_e: unknown, status: number) => ({ data: null, @@ -59,9 +67,11 @@ vi.mock("@/lib/api-response", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSelfContextForUser } from "@/lib/ai/coach/about-me"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const transaction = prisma.$transaction as ReturnType; const getCtx = getSelfContextForUser as ReturnType; +const gate = requireModuleEnabled as ReturnType; function adoptRequest(body: { question?: string; answer: string }): Request { return new Request("http://localhost/api/coach/about-me/adopt", { @@ -87,6 +97,29 @@ describe("POST /api/coach/about-me/adopt", () => { tx.userHealthProfile.upsert.mockResolvedValue({ id: "p1" }); tx.userHealthProfile.update.mockResolvedValue({ updatedAt: new Date() }); tx.$queryRaw.mockResolvedValue([{ id: "p1" }]); + gate.mockResolvedValue({ enabled: true }); + }); + + // v1.18.0 — Coach module gate (operator availability OR per-user + // disableCoach) short-circuits before the locked read-modify-write. + it("returns 403 module.disabled when Coach is disabled, without a write", async () => { + gate.mockResolvedValue({ + enabled: false, + response: { + data: null, + error: 'Module "coach" is not enabled', + status: 403, + meta: { errorCode: "module.disabled", module: "coach" }, + }, + }); + + const res = await post( + adoptRequest({ question: "What matters?", answer: "evening walks" }), + ); + + expect(res.status).toBe(403); + expect(gate).toHaveBeenCalledWith("u1", "coach"); + expect(transaction).not.toHaveBeenCalled(); }); it("runs the read-modify-write inside one transaction with a row lock", async () => { diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts index 5f8c1d8e5..5ffb8692d 100644 --- a/src/app/api/coach/about-me/adopt/route.ts +++ b/src/app/api/coach/about-me/adopt/route.ts @@ -41,6 +41,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { getSelfContextForUser } from "@/lib/ai/coach/about-me"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { ABOUT_ME_FIELD_MAX_CHARS, ABOUT_ME_MAX_CHARS, @@ -93,6 +94,9 @@ function normalise(s: string): string { export const POST = apiHandler(async (req: Request) => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const rl = await checkRateLimit( `coach-about-me:adopt:${user.id}`, diff --git a/src/app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts b/src/app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts new file mode 100644 index 000000000..6bbfb46da --- /dev/null +++ b/src/app/api/coach/about-me/questions/__tests__/route-module-gate.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.18.0 — the clarifying-questions GET/DELETE enforce the two-layer + * Coach module gate (operator availability AND the per-user + * `disableCoach` opt-out) right after auth. A disabled module returns + * the 403 `module.disabled` envelope before any pending-question read or + * mutation; an enabled module proceeds to the normal 200 path. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +const { requireModuleEnabled } = vi.hoisted(() => ({ + requireModuleEnabled: vi.fn(), +})); +vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + returnAllZodIssues: (_e: unknown, status: number) => ({ + data: null, + error: "validation", + status, + }), + safeJson: async () => ({ data: {}, error: null }), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getPendingQuestionsForUser: vi.fn(async () => ["q1"]), + setPendingQuestionsForUser: vi.fn(), + PENDING_QUESTION_MAX_CHARS: 200, +})); + +import { GET, DELETE } from "../route"; + +type Envelope = { + data: unknown; + error: string | null; + status: number; + meta?: { errorCode?: string; module?: string }; +}; +const get = GET as unknown as () => Promise; +const del = DELETE as unknown as (req: Request) => Promise; + +const disabledResponse: Envelope = { + data: null, + error: 'Module "coach" is not enabled', + status: 403, + meta: { errorCode: "module.disabled", module: "coach" }, +}; + +function delReq(): Request { + return new Request("http://localhost/api/coach/about-me/questions", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); +} + +describe("about-me questions module gate", () => { + beforeEach(() => vi.clearAllMocks()); + + it("GET returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + const res = await get(); + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("module.disabled"); + expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); + }); + + it("GET returns 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + const res = await get(); + expect(res.status).toBe(200); + }); + + it("DELETE returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + const res = await del(delReq()); + expect(res.status).toBe(403); + expect(res.meta?.module).toBe("coach"); + }); + + it("DELETE returns 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + const res = await del(delReq()); + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/coach/about-me/questions/route.ts b/src/app/api/coach/about-me/questions/route.ts index 1c4094d21..58841bd91 100644 --- a/src/app/api/coach/about-me/questions/route.ts +++ b/src/app/api/coach/about-me/questions/route.ts @@ -24,6 +24,7 @@ import { PENDING_QUESTION_MAX_CHARS, setPendingQuestionsForUser, } from "@/lib/ai/coach/about-me"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const dismissSchema = z.object({ /** Exact question text to dismiss. Omitted = dismiss all. */ @@ -32,6 +33,9 @@ const dismissSchema = z.object({ export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const questions = await getPendingQuestionsForUser(user.id); annotate({ action: { name: "coach.about_me.questions.get" }, @@ -42,6 +46,9 @@ export const GET = apiHandler(async () => { export const DELETE = apiHandler(async (req: Request) => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const { data: body, error: jsonError } = await safeJson(req, { maxBytes: 4 * 1024, diff --git a/src/app/api/coach/about-me/route.ts b/src/app/api/coach/about-me/route.ts index 3c2b18bc2..b594e772d 100644 --- a/src/app/api/coach/about-me/route.ts +++ b/src/app/api/coach/about-me/route.ts @@ -43,6 +43,7 @@ import { setPendingQuestionsForUser, } from "@/lib/ai/coach/about-me"; import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { ABOUT_ME_FIELD_MAX_CHARS, ABOUT_ME_MAX_CHARS, @@ -54,6 +55,9 @@ const PUT_WINDOW_MS = 60_000; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const [ctx, pendingQuestions, row] = await Promise.all([ getSelfContextForUser(user.id), @@ -89,6 +93,9 @@ export const GET = apiHandler(async () => { export const PUT = apiHandler(async (req: Request) => { const { user } = await requireAuth(); + // v1.18.0 — Coach module gate (operator availability + disableCoach). + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; const rl = await checkRateLimit( `coach-about-me:put:${user.id}`, diff --git a/src/app/api/doctor-report/__tests__/pdf-route.test.ts b/src/app/api/doctor-report/__tests__/pdf-route.test.ts deleted file mode 100644 index 50fb75504..000000000 --- a/src/app/api/doctor-report/__tests__/pdf-route.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -// --- Mocks must come before importing the route. --- - -vi.mock("@/lib/db", () => ({ - prisma: { - measurement: { findMany: vi.fn() }, - medication: { findMany: vi.fn() }, - medicationIntakeEvent: { findMany: vi.fn() }, - moodEntry: { findMany: vi.fn() }, - labResult: { findMany: vi.fn() }, - user: { findUnique: vi.fn(), update: vi.fn() }, - auditLog: { create: vi.fn() }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ - getSession: vi.fn(), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(), -})); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn(), -})); - -vi.mock("@/lib/geo", () => ({ - lookupIpLocation: vi.fn(), -})); - -vi.mock("@/lib/analytics/effective-range", () => ({ - getEffectiveRange: vi.fn(() => ({ range: null })), -})); - -vi.mock("@/lib/logging/transports", () => ({ - emitIfSampled: vi.fn(), -})); - -import { POST } from "../pdf/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { auditLog } from "@/lib/auth/audit"; - -function makeRequest( - body?: Record, - headers: Record = {}, -): NextRequest { - const baseHeaders: Record = { - "content-type": "application/json", - ...headers, - }; - return new NextRequest("http://localhost/api/doctor-report/pdf", { - method: "POST", - headers: baseHeaders, - body: body === undefined ? undefined : JSON.stringify(body), - }); -} - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) }, - user: { id: "user-1", role: "USER" as const }, -}; - -function setEmptyDataMocks() { - vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( - [] as never, - ); - vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - username: "testuser", - dateOfBirth: null, - gender: null, - heightCm: null, - glucoseUnit: null, - thresholdsJson: null, - } as never); -} - -describe("POST /api/doctor-report/pdf", () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it("returns 401 when not authenticated", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await POST(makeRequest({ days: 90 })); - expect(res.status).toBe(401); - const json = (await res.json()) as { error: string }; - expect(json.error).toBe("Not authenticated"); - }); - - it("returns 429 when rate limit exceeded", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 3600_000, - }); - - const res = await POST(makeRequest({ days: 90 })); - expect(res.status).toBe(429); - const json = (await res.json()) as { error: string }; - expect(json.error).toMatch(/Maximum/); - }); - - it("returns 200 application/pdf with PDF bytes on happy path", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const res = await POST( - makeRequest({ days: 30, locale: "de" }, { "accept-language": "de-DE" }), - ); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toBe("application/pdf"); - expect(res.headers.get("cache-control")).toBe("no-store"); - const disposition = res.headers.get("content-disposition") ?? ""; - expect(disposition).toMatch(/attachment;\s*filename="healthlog-report-/); - expect(disposition).toMatch(/\.pdf"/); - - const buf = Buffer.from(await res.arrayBuffer()); - expect(buf.byteLength).toBeGreaterThan(1024); - expect(buf.slice(0, 5).toString("utf8")).toBe("%PDF-"); - - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ days: 30, locale: "de" }), - }), - ); - }); - - it("falls back to default days=90 when body omits it", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const res = await POST(makeRequest({})); - expect(res.status).toBe(200); - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ days: 90 }), - }), - ); - }); - - it("uses Accept-Language to pick the locale when body omits it", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const res = await POST( - makeRequest({ days: 7 }, { "accept-language": "en-US,en;q=0.9" }), - ); - expect(res.status).toBe(200); - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ locale: "en" }), - }), - ); - }); - - // ── v1.4.15 phase B6 ── configurable date range + practice name. - - it("accepts an explicit startDate / endDate range in the body", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const startDate = "2026-01-01T00:00:00.000Z"; - const endDate = "2026-04-01T00:00:00.000Z"; - const res = await POST(makeRequest({ startDate, endDate })); - expect(res.status).toBe(200); - expect(prisma.measurement.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - measuredAt: { gte: new Date(startDate), lte: new Date(endDate) }, - }), - }), - ); - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ startDate, endDate }), - }), - ); - }); - - it("falls back when range is invalid (end < start)", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - - const res = await POST( - makeRequest({ - startDate: "2026-04-01T00:00:00.000Z", - endDate: "2026-01-01T00:00:00.000Z", - }), - ); - expect(res.status).toBe(200); - // Falls back to days=90. - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ days: 90 }), - }), - ); - }); - - it("persists practiceName as a user preference and includes it in audit", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 9, - resetAt: Date.now() + 3600_000, - }); - vi.mocked(auditLog).mockResolvedValue(undefined as never); - setEmptyDataMocks(); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await POST( - makeRequest({ days: 30, practiceName: "Praxis Dr. Müller" }), - ); - expect(res.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { lastReportPracticeName: "Praxis Dr. Müller" }, - }); - expect(auditLog).toHaveBeenCalledWith( - "doctor-report.pdf.generate", - expect.objectContaining({ - details: expect.objectContaining({ practiceNameProvided: true }), - }), - ); - }); -}); diff --git a/src/app/api/doctor-report/availability/route.ts b/src/app/api/doctor-report/availability/route.ts deleted file mode 100644 index 209f04966..000000000 --- a/src/app/api/doctor-report/availability/route.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * v1.4.25 W6c — doctor-report section availability probe. - * - * Returns one boolean per toggleable section so the dialog can hide - * checkboxes for data types that have zero rows in the user-selected - * date range (the maintainer's "hide-when-empty" rule). Pure presence check — - * `count` queries on small indexed tables — so this is cheap enough to - * run on every date-range change in the dialog without driving cost. - * - * Body shape (mirrors `/api/doctor-report`): - * { startDate?: ISO, endDate?: ISO, days?: number } - */ -import type { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { apiSuccess, safeJson } from "@/lib/api-response"; -import { prisma } from "@/lib/db"; -import { normaliseDateRange } from "@/lib/doctor-report-data"; - -export const dynamic = "force-dynamic"; - -export interface DoctorReportAvailability { - /** Section flags — `true` when at least one row exists in the range. */ - bp: boolean; - weight: boolean; - pulse: boolean; - /** BMI requires both a weight reading AND a configured height. */ - bmi: boolean; - mood: boolean; - /** Medication compliance — derived from `MedicationIntakeEvent` rows. */ - compliance: boolean; - sleep: boolean; -} - -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - annotate({ action: { name: "doctor-report.availability" } }); - - const { data: body, error } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (error) return error; - const range = normaliseDateRange(body ?? undefined); - const { start, end } = range; - - // `count` queries are cheaper than `findMany` for a presence check — - // we never read the rows themselves. The user has indexes on - // `(userId, measuredAt)`, `(userId, moodLoggedAt)`, and - // `(userId, scheduledFor)` so these stay sub-millisecond even with - // multi-year ranges. - const [ - bpCount, - weightCount, - pulseCount, - moodCount, - complianceCount, - sleepCount, - profile, - ] = await Promise.all([ - prisma.measurement.count({ - where: { - userId: user.id, - measuredAt: { gte: start, lte: end }, - type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, - // v1.4.41 W-DELETED-2 — hide soft-deleted rows from the - // section-availability probe so the dialog does not offer a - // section that would render an empty PDF block. - deletedAt: null, - }, - }), - prisma.measurement.count({ - where: { - userId: user.id, - measuredAt: { gte: start, lte: end }, - type: "WEIGHT", - deletedAt: null, - }, - }), - prisma.measurement.count({ - where: { - userId: user.id, - measuredAt: { gte: start, lte: end }, - type: "PULSE", - deletedAt: null, - }, - }), - prisma.moodEntry.count({ - // v1.7.0 sync — exclude tombstoned rows from the availability count. - where: { - userId: user.id, - deletedAt: null, - moodLoggedAt: { gte: start, lte: end }, - }, - }), - prisma.medicationIntakeEvent.count({ - // v1.7.0 sync — exclude tombstoned rows from the availability count. - where: { - userId: user.id, - deletedAt: null, - scheduledFor: { gte: start, lte: end }, - }, - }), - prisma.measurement.count({ - where: { - userId: user.id, - measuredAt: { gte: start, lte: end }, - type: "SLEEP_DURATION", - deletedAt: null, - }, - }), - prisma.user.findUnique({ - where: { id: user.id }, - select: { heightCm: true }, - }), - ]); - - // BMI is derived (latest weight × profile height) so it only shows up - // when BOTH inputs are available — otherwise the user would tick a - // box that produces a silently-empty section. - const bmi = weightCount > 0 && (profile?.heightCm ?? null) !== null; - - const availability: DoctorReportAvailability = { - bp: bpCount > 0, - weight: weightCount > 0, - pulse: pulseCount > 0, - bmi, - mood: moodCount > 0, - compliance: complianceCount > 0, - sleep: sleepCount > 0, - }; - - annotate({ - meta: { - report_days: range.days, - sections_available: Object.values(availability).filter(Boolean).length, - }, - }); - - return apiSuccess(availability); -}); diff --git a/src/app/api/doctor-report/pdf/route.ts b/src/app/api/doctor-report/pdf/route.ts deleted file mode 100644 index d64f12467..000000000 --- a/src/app/api/doctor-report/pdf/route.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { apiError, getClientIp } from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { - collectDoctorReportData, - normaliseDateRange, - sanitisePracticeName, -} from "@/lib/doctor-report-data"; -import { prisma } from "@/lib/db"; -import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core"; -import { - DEFAULT_DOCTOR_REPORT_PREFS, - doctorReportPrefsSchema, - type DoctorReportPrefs, -} from "@/lib/validations/doctor-report-prefs"; -import { getServerTranslator } from "@/lib/i18n/server-translator"; -import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; -import { locales, type Locale } from "@/lib/i18n/config"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; - -/** - * Server-rendered PDF doctor report. - * - * Mirrors `/api/doctor-report` (which returns JSON for client-side rendering) - * but produces the finished PDF bytes server-side. Useful on iOS / Safari where - * the client-side jsPDF download UX is unreliable. - * - * Auth, rate-limit (10/h), and audit-log policy match the JSON endpoint. - */ -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - annotate({ action: { name: "doctor-report.pdf.generate" } }); - - const rl = await checkRateLimit( - `doctor-report:${user.id}`, - 10, - 60 * 60 * 1000, - ); - if (!rl.allowed) { - return apiError("Maximum 10 reports per hour", 429); - } - - // Body is optional — JSON is allowed but not required. - const body = await readOptionalJsonBody(request); - const range = normaliseDateRange(body ?? undefined); - const locale = resolveLocale( - body?.locale, - request.headers.get("accept-language"), - ); - const rawPracticeName = body?.practiceName; - const practiceName = sanitisePracticeName(rawPracticeName); - - // v1.4.25 W6c — section toggles. Malformed shapes silently fall back - // to documented defaults so a forward-compat client never lands a - // 422 on a benign drift. - const sectionsParsed = doctorReportPrefsSchema.safeParse( - body?.sections ?? {}, - ); - const sectionsInput = sectionsParsed.success ? sectionsParsed.data : {}; - const sections: DoctorReportPrefs = { - ...DEFAULT_DOCTOR_REPORT_PREFS, - ...sectionsInput, - }; - - if (typeof rawPracticeName === "string" && practiceName !== null) { - try { - await prisma.user.update({ - where: { id: user.id }, - data: { lastReportPracticeName: practiceName }, - }); - } catch { - // Best-effort; never let preference persistence break the report. - } - } - - const [data, userTz] = await Promise.all([ - collectDoctorReportData(user.id, range, { practiceName, sections }), - resolveUserTimezone(user.id), - ]); - - const { t } = getServerTranslator(locale); - const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale, userTz }); - - await auditLog("doctor-report.pdf.generate", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - days: range.days, - startDate: range.start.toISOString(), - endDate: range.end.toISOString(), - locale, - practiceNameProvided: practiceName !== null, - // v1.4.25 W6c — record exactly which sections this report - // rendered, so a future privacy review can confirm mood was - // never rendered when the user opted out. - sections, - }, - }); - - annotate({ - meta: { - report_days: range.days, - report_start: range.start.toISOString(), - report_end: range.end.toISOString(), - locale, - bytes: pdfBytes.byteLength, - practice_name_provided: practiceName !== null, - }, - }); - - const filename = `healthlog-report-${new Date().toISOString().slice(0, 10)}.pdf`; - // NextResponse accepts a BodyInit; copy to a fresh ArrayBuffer to avoid - // SharedArrayBuffer typing surprises with Uint8Array. - const buffer = pdfBytes.slice().buffer; - return new NextResponse(buffer, { - status: 200, - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename="${filename}"`, - "Content-Length": String(pdfBytes.byteLength), - "Cache-Control": "no-store", - }, - }); -}); - -interface PdfRequestBody { - days?: unknown; - startDate?: unknown; - endDate?: unknown; - locale?: unknown; - practiceName?: unknown; - /** v1.4.25 W6c — per-section visibility toggles. */ - sections?: unknown; -} - -/** - * Best-effort JSON body parse. Returns `null` if the request had no body or - * a non-JSON content type — both are acceptable for this endpoint. - */ -async function readOptionalJsonBody( - request: NextRequest, -): Promise { - const contentType = request.headers.get("content-type") ?? ""; - if (!contentType.includes("application/json")) return null; - try { - const raw = await request.text(); - // Small options object — treat an oversized body as no body. - if (raw.length > 64 * 1024) return null; - return JSON.parse(raw) as PdfRequestBody; - } catch { - return null; - } -} - -/** - * Resolve the active locale: explicit body override wins, then a parsed - * `Accept-Language`, then a hard fallback to German (the primary HealthLog - * locale for the medical context). - */ -function resolveLocale( - bodyLocale: unknown, - acceptLanguage: string | null, -): Locale { - if ( - typeof bodyLocale === "string" && - (locales as readonly string[]).includes(bodyLocale) - ) { - return bodyLocale as Locale; - } - if (!acceptLanguage) return "de"; - // parseLocaleFromAcceptLanguage returns "de" for de-* headers, otherwise - // "en" — matching the broader app's i18n behaviour for present headers. - return parseLocaleFromAcceptLanguage(acceptLanguage); -} diff --git a/src/app/api/doctor-report/route.ts b/src/app/api/doctor-report/route.ts deleted file mode 100644 index 776009f70..000000000 --- a/src/app/api/doctor-report/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { - apiSuccess, - apiError, - getClientIp, - safeJson, -} from "@/lib/api-response"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { NextRequest } from "next/server"; -import { - collectDoctorReportData, - normaliseDateRange, - sanitisePracticeName, -} from "@/lib/doctor-report-data"; -import { prisma } from "@/lib/db"; -import { doctorReportPrefsSchema } from "@/lib/validations/doctor-report-prefs"; - -/** - * Collect data for doctor report PDF generation (client-side). - * Returns aggregated health data for the specified time range. - * - * Body shape (all fields optional — sensible defaults apply): - * { - * startDate?: string, // ISO timestamp, inclusive - * endDate?: string, // ISO timestamp, inclusive - * days?: number, // legacy "last N days" fallback (1..365) - * practiceName?: string // free-text, persisted as user preference - * } - * - * Resolution: explicit range wins; otherwise `days` (default 90) is applied. - */ -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - annotate({ action: { name: "doctor-report.generate" } }); - - const rl = await checkRateLimit( - `doctor-report:${user.id}`, - 10, - 60 * 60 * 1000, - ); - if (!rl.allowed) { - return apiError("Maximum 10 reports per hour", 429); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const range = normaliseDateRange(body); - const rawPracticeName = (body as Record | null) - ?.practiceName; - const practiceName = sanitisePracticeName(rawPracticeName); - - // v1.4.25 W6c — section toggles. Malformed shapes silently fall - // through to the documented defaults so a forward-compat client - // never lands a 422 on a benign drift. - const rawSections = (body as Record | null)?.sections; - const sectionsParsed = doctorReportPrefsSchema.safeParse(rawSections ?? {}); - const sections = sectionsParsed.success ? sectionsParsed.data : {}; - - // Persist the most-recent practice name as a user preference so the - // dialog can pre-fill it next time. We only write when the caller - // actually supplied a non-empty string — passing `null`/empty does NOT - // clear the stored preference (use the dedicated profile endpoint for - // that). Best-effort: a write failure here MUST NOT break the report. - if (typeof rawPracticeName === "string" && practiceName !== null) { - try { - await prisma.user.update({ - where: { id: user.id }, - data: { lastReportPracticeName: practiceName }, - }); - } catch { - // Non-fatal — preference persistence is a UX nicety, not a contract. - } - } - - const data = await collectDoctorReportData(user.id, range, { - practiceName, - sections, - }); - - await auditLog("doctor-report.generate", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - days: range.days, - startDate: range.start.toISOString(), - endDate: range.end.toISOString(), - practiceNameProvided: practiceName !== null, - // v1.4.25 W6c — record which sections were included. The audit - // trail proves mood data was never aggregated when the toggle - // was off (privacy contract). - sections, - }, - }); - - annotate({ - meta: { - report_days: range.days, - report_start: range.start.toISOString(), - report_end: range.end.toISOString(), - practice_name_provided: practiceName !== null, - }, - }); - - return apiSuccess(data); -}); diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 04e454684..243248566 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -4,7 +4,6 @@ * - /api/export (legacy CSV/JSON endpoint), * - /api/export/full-backup (single-file JSON bundle), * - /api/export/measurements (per-type CSV), - * - /api/doctor-report/availability (section probe), * - /api/gamification/achievements (achievement progress aggregator). * * Every assertion is shape-level: the route's measurement read MUST scope @@ -51,9 +50,27 @@ vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), })); +// v1.18.0 — the achievements route gates on `requireModuleEnabled`. Stub +// the gate to "all modules enabled" (an empty map ⇒ default-on) so this +// pre-existing soft-delete test doesn't stand up the real gate's DB reads. +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(), + isModuleEnabled: vi.fn(), + requireModuleEnabled: vi.fn(), + }; +}); + import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + resolveModuleMap, + isModuleEnabled, + requireModuleEnabled, +} from "@/lib/modules/gate"; const SESSION_OK = { user: { @@ -69,6 +86,9 @@ function mkReq(url: string): NextRequest { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(resolveModuleMap).mockResolvedValue({} as never); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, @@ -135,30 +155,4 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { }); } }); - - it("/api/doctor-report/availability scopes every measurement count to deletedAt: null", async () => { - const { POST } = await import( - "../../doctor-report/availability/route" - ); - const req = new NextRequest( - "http://localhost/api/doctor-report/availability", - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({}), - }, - ); - await POST(req); - const calls = vi.mocked(prisma.measurement.count).mock.calls; - // The probe runs four parallel measurement.count queries (BP / - // weight / pulse / sleep). All must filter tombstoned rows so a - // soft-deleted history does not light up a section the user has - // since wiped. - expect(calls.length).toBeGreaterThanOrEqual(4); - for (const [arg] of calls) { - expect(arg).toMatchObject({ - where: expect.objectContaining({ deletedAt: null }), - }); - } - }); }); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index bc8e1a7c0..661a4fa47 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -32,9 +32,28 @@ vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"), })); +// v1.18.0 — the health-record aggregator resolves the per-user module map +// so a disabled data-domain module never reaches the export. Stub the gate +// to "all modules enabled" (an empty map ⇒ default-on) so these pre-existing +// route tests don't stand up the real gate's DB reads. +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(), + isModuleEnabled: vi.fn(), + requireModuleEnabled: vi.fn(), + }; +}); + import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + resolveModuleMap, + isModuleEnabled, + requireModuleEnabled, +} from "@/lib/modules/gate"; const SESSION_OK = { user: { id: "user-1", email: "test@example.com", role: "USER" }, @@ -63,6 +82,9 @@ async function pdfText(res: Response): Promise { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(resolveModuleMap).mockResolvedValue({} as never); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, @@ -110,6 +132,21 @@ describe("POST /api/export/health-record — validation", () => { expect(res.status).toBe(422); }); + it("returns 403 when the doctorReport module is disabled (B3 gate)", async () => { + const { apiError } = await import("@/lib/api-response"); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError('Module "doctorReport" is not enabled', 403, { + errorCode: "module.disabled", + module: "doctorReport", + }), + } as never); + const { POST } = await import("../route"); + const res = await POST(mkReq({ format: "fhir" })); + expect(res.status).toBe(403); + expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "doctorReport"); + }); + it("returns 429 when the export rate limit is exhausted", async () => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false, diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 073af830a..a236ec3ca 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -31,6 +31,7 @@ import { safeJson, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; import { @@ -56,6 +57,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "export.health-record.build" } }); + // v1.18.0 B3 — the whole doctor-report / health-record surface is the + // `doctorReport` module. Refuse with a 403 `module.disabled` envelope when + // the account turned it off — even with a valid Bearer token. The settings + // entry-point already hides client-side; this is the hard enforcement. + const gate = await requireModuleEnabled(user.id, "doctorReport"); + if (!gate.enabled) return gate.response; + const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000); if (!rl.allowed) { return apiError("Maximum 10 exports per hour", 429); diff --git a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts index 69d8ad58e..0b419743c 100644 --- a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts +++ b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts @@ -19,6 +19,19 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +// v1.18.0 — this suite predates the achievements module gate and only +// mocks the aggregation Prisma models. Pin the gate to "enabled" so it +// stays focused on the hidden-Easter-egg redaction contract; the gate's +// own enable/disable behaviour is covered in module-gate.test.ts. +vi.mock("@/lib/modules/gate", () => ({ + // Plain async fn (not a `vi.fn`) so `vi.resetAllMocks()` in beforeEach + // can't blank it out and flip the gate to a falsy decision. + requireModuleEnabled: async () => ({ enabled: true }), + // v1.18.0 B5 — all modules on so the per-module badge filter is inert. + resolveModuleMap: async () => ({}), + MODULE_DISABLED_ERROR_CODE: "module.disabled", +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ diff --git a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts index 9d46e416f..313813feb 100644 --- a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts +++ b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts @@ -19,6 +19,19 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +// v1.18.0 — this suite predates the achievements module gate and only +// mocks the aggregation Prisma models. Pin the gate to "enabled" (a plain +// async fn so `vi.resetAllMocks()` can't blank it out) so the format +// branches stay in scope; the gate's enable/disable behaviour is covered +// in module-gate.test.ts. +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: async () => ({ enabled: true }), + // v1.18.0 B5 — all modules enabled by default so the badge filter is + // a no-op here; the skip behaviour is asserted in module-gate.test.ts. + resolveModuleMap: async () => ({}), + MODULE_DISABLED_ERROR_CODE: "module.disabled", +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ @@ -102,6 +115,11 @@ describe("GET /api/gamification/achievements?format=ios", () => { unlocked: boolean; unlockedAt: string | null; progress: number; + category: string; + points: number; + target: number; + current: number; + isHidden: boolean; }>; }; expect(Array.isArray(body.data)).toBe(true); @@ -113,6 +131,12 @@ describe("GET /api/gamification/achievements?format=ios", () => { expect(first.iconName).toEqual(expect.any(String)); expect(first.progress).toBeGreaterThanOrEqual(0); expect(first.progress).toBeLessThanOrEqual(1); + // v1.18.0 B5 — parity fields the iOS DTO previously dropped. + expect(first.category).toEqual(expect.any(String)); + expect(first.points).toEqual(expect.any(Number)); + expect(first.target).toEqual(expect.any(Number)); + expect(first.current).toEqual(expect.any(Number)); + expect(first.isHidden).toEqual(expect.any(Boolean)); }); it("falls back to the legacy wrapped shape when no format is given", async () => { diff --git a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts new file mode 100644 index 000000000..16d5465d1 --- /dev/null +++ b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.18.0 — the achievements module gate on + * `GET /api/gamification/achievements`. + * + * Disabled ⇒ a 403 `module.disabled` envelope, and the heavy badge + * aggregation never runs (no Prisma reads, no unlock + * persistence). This is what makes the surface DISAPPEAR for + * an account that has turned the module off — the web page, + * the dashboard tile, and the unlock notifier all gate the + * same route and the same `/api/auth/me` module map. + * Enabled ⇒ the route behaves exactly as before (200 + payload). + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + passkey: { findMany: vi.fn() }, + auditLog: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + userAchievement: { + findMany: vi.fn(), + createMany: vi.fn(), + }, + userHealthProfile: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// The module gate is mocked directly so this test asserts the route's +// branch on the gate decision, not the resolver internals (those have +// their own unit coverage in src/lib/modules/__tests__/gate.test.ts). +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), + resolveModuleMap: vi.fn(), + MODULE_DISABLED_ERROR_CODE: "module.disabled", +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { apiError } from "@/lib/api-response"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + heightCm: null, + locale: "en", + }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( + null as never, + ); + // v1.18.0 B5 — default to "every module on" so individual cases only + // flip the one key under test. + vi.mocked(resolveModuleMap).mockResolvedValue({} as never); +}); + +describe("GET /api/gamification/achievements — achievements module gate", () => { + it("returns the 403 module.disabled envelope when the module is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError("Module \"achievements\" is not enabled", 403, { + errorCode: "module.disabled", + module: "achievements", + }), + }); + + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + + expect(res.status).toBe(403); + const body = (await res.json()) as { + data: null; + error: string; + meta: { errorCode: string; module: string }; + }; + expect(body.data).toBeNull(); + expect(body.meta.errorCode).toBe("module.disabled"); + expect(body.meta.module).toBe("achievements"); + + // The disappearance is total: no badge aggregation ran. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + expect(prisma.userAchievement.findMany).not.toHaveBeenCalled(); + expect(prisma.userAchievement.createMany).not.toHaveBeenCalled(); + }); + + it("serves the payload normally when the module is enabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { achievements: unknown[]; summary: unknown }; + }; + expect(body.data.achievements).toBeDefined(); + expect(body.data.summary).toBeDefined(); + expect(prisma.userAchievement.findMany).toHaveBeenCalled(); + }); + + it("still 401s before the module gate when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + + expect(res.status).toBe(401); + // Auth is the outer guard — the module gate is never consulted. + expect(requireModuleEnabled).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/gamification/achievements — per-module badge skipping", () => { + // Three mood entries make the mood badges earnable (hasMood = true) so + // they survive the discovery filter when the mood module is ON — which + // means a later disappearance can only be the B5 module filter. + const MOOD_ENTRIES = [ + { + date: "2026-03-02", + score: 4, + moodLoggedAt: new Date("2026-03-02T08:00:00.000Z"), + }, + { + date: "2026-03-03", + score: 5, + moodLoggedAt: new Date("2026-03-03T08:00:00.000Z"), + }, + { + date: "2026-03-04", + score: 5, + moodLoggedAt: new Date("2026-03-04T08:00:00.000Z"), + }, + ]; + + type Badge = { id: string; category: string; metric: string }; + + async function badgesFor( + moduleMap: Record, + ): Promise { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(resolveModuleMap).mockResolvedValue(moduleMap as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue( + MOOD_ENTRIES as never, + ); + + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { achievements: Badge[] } }; + return body.data.achievements; + } + + it("includes mood badges when the mood module is enabled", async () => { + const badges = await badgesFor({ mood: true }); + expect(badges.some((b) => b.category === "mood")).toBe(true); + }); + + it("drops every mood badge when the mood module is disabled", async () => { + const badges = await badgesFor({ mood: false }); + expect(badges.some((b) => b.category === "mood")).toBe(false); + // Non-mood badges still render — the filter is surgical, not global. + expect(badges.length).toBeGreaterThan(0); + }); + + it("never persists an unlock for a disabled-module badge", async () => { + // A mood badge would unlock at >=1 entry; with mood OFF the createMany + // must carry no mood achievement row. + await badgesFor({ mood: false }); + const createCalls = vi.mocked(prisma.userAchievement.createMany).mock + .calls; + for (const [arg] of createCalls) { + const rows = (arg as { data: Array<{ achievementId: string }> }).data; + expect(rows.some((r) => r.achievementId.includes("mood"))).toBe(false); + } + }); +}); diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index e521401e8..2b1821386 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -1,5 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import type { ModuleKey } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; @@ -9,6 +11,7 @@ import { applyDiscoveryFilter, calculateLongestStreak, evaluateAchievementsWithCompletionDates, + moduleForMetric, toBerlinDayKey, type AchievementMetrics, type AchievementProgress, @@ -40,6 +43,15 @@ interface IosAchievement { unlocked: boolean; unlockedAt: string | null; progress: number; + // v1.18.0 B5 — parity fields the web payload already carries; the iOS + // client needs them to group badges by category, render the points + // tally, show absolute progress (current / target) and the opaque + // hidden-card placeholder in lock-step with the web surface. + category: string; + points: number; + target: number; + current: number; + isHidden: boolean; } export const dynamic = "force-dynamic"; @@ -469,6 +481,21 @@ function getCompliance80DaySeries( export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + + // v1.18.0 — when the account has the achievements module turned off the + // whole gamification surface disappears: no badge evaluation, no unlock + // persistence, no payload. Returns the 403 `module.disabled` envelope + // verbatim so the client (web + iOS) hides the page / dashboard tile / + // unlock toast in lock-step with this refusal. + const gate = await requireModuleEnabled(user.id, "achievements"); + if (!gate.enabled) { + annotate({ + action: { name: "gamification.achievements" }, + meta: { moduleDisabled: true }, + }); + return gate.response; + } + const formatParam = request.nextUrl.searchParams.get("format"); const isIosFormat = formatParam === "ios"; annotate({ @@ -480,23 +507,57 @@ export const GET = apiHandler(async (request: NextRequest) => { // iOS-format branch runs the locale-aware transform after the cache // read so the cache stays format-agnostic and the achievement-progress // dashboard duplicate (seen twice per dashboard mount in the v1.4.33 - // HAR) coalesces into one builder call. Newly-unlocked achievements - // get persisted inside the builder so the second concurrent mount - // observes the up-to-date `persisted` state. + // HAR) coalesces into one builder call. v1.18.0 B5 — unlock persistence + // now happens in the handler after the cache read (idempotent), not as + // a side effect inside the cached factory. + // v1.18.0 B5 — resolve the per-user module map once and pass it into the + // builder so badge categories whose owning module is disabled (sleep + // badges when sleep is off, mood badges when mood is off) are skipped + // from evaluation AND unlock-persistence. Resolved outside the cache so + // a toggle change is reflected on the next read. + const moduleMap = await resolveModuleMap(user.id); + const result = await cached( caches.achievements as ServerCache, user.id, - () => buildAchievementsResult(user), + () => buildAchievementsResult(user, moduleMap), annotate, ); + // v1.18.0 B5 — persist newly unlocked achievements OUTSIDE the cached + // factory. `createMany({ skipDuplicates: true })` is idempotent on the + // `(userId, achievementId)` unique, so re-running it on a cache hit is + // a no-op rather than a duplicate, and the write is never skipped just + // because the read was served from cache. + if (result.pendingUnlocks.length > 0) { + await prisma.userAchievement.createMany({ + data: result.pendingUnlocks.map((u) => ({ + userId: user.id, + achievementId: u.achievementId, + unlockedAt: new Date(u.unlockedAt), + })), + skipDuplicates: true, + }); + annotate({ + action: { name: "gamification.achievements" }, + meta: { newUnlocks: result.pendingUnlocks.length }, + }); + } + + // Strip the internal `pendingUnlocks` carrier; it never goes on the wire. + const payload = { + summary: result.summary, + achievements: result.achievements, + metrics: result.metrics, + }; + if (isIosFormat) { const locale = await resolveServerLocale({ request, userLocale: user.locale, }); const t = getServerTranslator(locale); - const ios: IosAchievement[] = result.achievements.map((a) => ({ + const ios: IosAchievement[] = payload.achievements.map((a) => ({ id: a.id, key: a.id, title: t.t(a.titleKey), @@ -505,11 +566,16 @@ export const GET = apiHandler(async (request: NextRequest) => { unlocked: a.unlocked, unlockedAt: a.completedAt, progress: Math.max(0, Math.min(1, a.progressPercent / 100)), + category: a.category, + points: a.points, + target: a.target, + current: a.current, + isHidden: a.isHidden, })); return apiSuccess(ios); } - return apiSuccess(result); + return apiSuccess(payload); }); type AuthedUser = Awaited>["user"]; @@ -521,13 +587,18 @@ type AchievementsResult = Awaited>; * the heavy aggregation. Returns the locale-agnostic web payload; the * iOS-format transform runs in the handler after the cache read. * - * `prisma.userAchievement.createMany` inside the builder is kept here - * (rather than moved to the cache hit path) because new unlocks are - * a side effect of the same aggregate computation. A duplicate concurrent - * read coalesces through the cache's single-flight `pending` map so the - * createMany only fires once. + * v1.18.0 B5 — this builder NO LONGER writes. It computes the not-yet- + * persisted unlocks and returns them as `pendingUnlocks`; the handler + * performs the idempotent `createMany({ skipDuplicates: true })` on every + * request after the cache read. That keeps the persistence off the cached + * path (a cache hit can't silently skip it, a concurrent miss can't + * duplicate it) while the unique `(userId, achievementId)` makes a repeat + * write a no-op. */ -async function buildAchievementsResult(user: AuthedUser) { +async function buildAchievementsResult( + user: AuthedUser, + moduleMap: Record, +) { const now = new Date(); const userId = user.id; const startDate = maxDate(user.createdAt, GAMIFICATION_ROLLOUT_AT); @@ -959,23 +1030,32 @@ async function buildAchievementsResult(user: AuthedUser) { mergedDates, ); - // Persist newly unlocked achievements - const newUnlocks = fullResult.achievements.filter( - (a) => - a.unlocked && - a.completedAt && - !persisted.some((p) => p.achievementId === a.id), - ); - if (newUnlocks.length > 0) { - await prisma.userAchievement.createMany({ - data: newUnlocks.map((a) => ({ - userId, - achievementId: a.id, - unlockedAt: new Date(a.completedAt!), - })), - skipDuplicates: true, - }); - } + // v1.18.0 B5 — drop badges whose owning module is disabled BEFORE both + // unlock-persistence and serialisation. A sleep badge must never unlock + // (or render) while the sleep module is off; same for mood badges when + // mood is off, etc. Core-domain and account-wide badges return a null + // owner from `moduleForMetric` and always pass through. + const moduleEnabledAchievements = fullResult.achievements.filter((a) => { + const owner = moduleForMetric(a.metric); + if (owner === null) return true; + return moduleMap[owner as ModuleKey] !== false; + }); + + // v1.18.0 B5 — compute the newly unlocked rows here but DON'T write them + // inside the cached factory: a write must not be a side effect of a + // cached GET (a cache hit would silently skip it, a concurrent miss + // could duplicate it). The handler performs the idempotent + // `createMany({ skipDuplicates: true })` on every request after the + // cache read, so the persistence runs regardless of cache outcome and + // is safe to repeat. + const pendingUnlocks: PendingUnlock[] = moduleEnabledAchievements + .filter( + (a) => + a.unlocked && + a.completedAt && + !persisted.some((p) => p.achievementId === a.id), + ) + .map((a) => ({ achievementId: a.id, unlockedAt: a.completedAt! })); // v1.4.18 — apply the discovery filter so locked badges that the // user has *no path* to earn (e.g. mood badges for someone who has @@ -983,7 +1063,7 @@ async function buildAchievementsResult(user: AuthedUser) { // eggs always pass through. Already-unlocked badges always pass // through (regression guard). const visibleAchievements = applyDiscoveryFilter( - fullResult.achievements, + moduleEnabledAchievements, earnability, ); @@ -1035,11 +1115,21 @@ async function buildAchievementsResult(user: AuthedUser) { }, achievements: redactedAchievements, metrics: redactedMetrics, + // v1.18.0 B5 — carried out of the cached factory so the handler can + // persist them on every read (idempotent), not as a write side effect + // that a cache hit would skip. NOT part of the serialised payload. + pendingUnlocks, }; return result; } +/** v1.18.0 B5 — a not-yet-persisted unlock the handler writes after the cache read. */ +interface PendingUnlock { + achievementId: string; + unlockedAt: string; +} + /** * Hidden Easter-eggs ship to the client *only* as opaque cards while * locked. Returning the full `AchievementProgress` shape would let a diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index 8c56d6382..178b312e0 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -7,6 +7,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// ride through; the off → 403 coverage lives in the route-gate inventory +// test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -42,6 +52,8 @@ vi.mock("@/lib/insights/blood-pressure-status", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { apiError } from "@/lib/api-response"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -58,6 +70,7 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); }); describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () => { @@ -89,3 +102,33 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () = expect(res.status).toBe(401); }); }); + +// v1.18.0 (B2) — the route now also requires the `insights` module. +// Representative coverage that a disabled module short-circuits with the +// 403 module.disabled envelope before any provider / cache work; the +// inventory test guards that EVERY insights AI route carries this gate. +describe("GET /api/insights/blood-pressure-status — insights module gate", () => { + it("returns 200 when insights is enabled (default)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + }); + + it("returns 403 + module.disabled when insights is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: apiError('Module "insights" is not enabled', 403, { + errorCode: "module.disabled", + module: "insights", + }), + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { + meta?: { errorCode?: string; module?: string }; + }; + expect(body.meta?.errorCode).toBe("module.disabled"); + expect(body.meta?.module).toBe("insights"); + }); +}); diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index cef396472..25e3af1da 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/insights/bmi-status/route.ts b/src/app/api/insights/bmi-status/route.ts index ac3620072..adfc00242 100644 --- a/src/app/api/insights/bmi-status/route.ts +++ b/src/app/api/insights/bmi-status/route.ts @@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 0f6b2abd0..4b6b12eac 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -14,6 +14,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// (data shape + assistant-flag gating) ride through; the dedicated +// off → 403 coverage lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -37,6 +47,7 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { getSession } from "@/lib/auth/session"; const SESSION_OK = { @@ -46,6 +57,7 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 3e91d9748..3d83207cc 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -8,6 +8,7 @@ */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; @@ -57,6 +58,8 @@ export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; // v1.4.31 — the iOS cards adapter feeds the same per-metric // insight surfaces the web `` mounts on each // /insights/ sub-page. Both share the operator gate. diff --git a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts new file mode 100644 index 000000000..9ab9c6793 --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * v1.18.0 — the Coach chat POST (SSE) and the conversation-list GET both + * enforce the two-layer module gate (operator availability AND the + * per-user `disableCoach` opt-out) right after auth. A disabled module + * returns the 403 `module.disabled` envelope verbatim before any other + * Coach dependency runs; an enabled module falls through to the existing + * assistant-flag check. The suite pins both branches so a refactor can't + * quietly drop the gate. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + HttpError: class HttpError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }, +})); + +// The legacy assistant-flag gate the handler reaches AFTER the module +// gate. We throw a sentinel so an enabled module is observable by the +// control reaching this call. +class AssistantSentinel extends Error {} + +const { requireModuleEnabled, requireAssistantSurface } = vi.hoisted(() => ({ + requireModuleEnabled: vi.fn(), + requireAssistantSurface: vi.fn(), +})); +vi.mock("@/lib/modules/gate", () => ({ requireModuleEnabled })); +vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface })); + +// Everything else the module imports — stubbed harmlessly; none of it is +// reached on the disabled branch, and the enabled branch stops at the +// assistant sentinel above. +vi.mock("@/lib/api-response", () => ({ + apiError: (error: string, status: number, meta?: unknown) => ({ + data: null, + error, + status, + meta, + }), + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn(async () => "en"), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: vi.fn(), + resolveProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() })); +vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +vi.mock("@/lib/ai/coach/types", () => ({ + coachChatRequestSchema: { safeParse: () => ({ success: false }) }, +})); +vi.mock("@/lib/ai/coach/persistence", () => ({ + appendMessage: vi.fn(), + createConversation: vi.fn(), + fetchConversationWithMessages: vi.fn(), + listConversations: vi.fn(async () => ({ conversations: [], nextCursor: null })), +})); +vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({ + enqueueCoachMemoryRefresh: vi.fn(), +})); +vi.mock("@/lib/ai/coach/facts", () => ({ storeDeterministicFacts: vi.fn() })); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(), + enforceBudget: vi.fn(), + recordSpend: vi.fn(), +})); +vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); +vi.mock("@/lib/ai/coach/system-prompt", () => ({ getCoachSystemPrompt: vi.fn() })); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(), +})); +vi.mock("@/lib/ai/coach/snapshot", () => ({ buildCoachSnapshot: vi.fn() })); +vi.mock("@/lib/ai/coach/keyvalues", () => ({ parseKeyValuesSentinel: vi.fn() })); +vi.mock("@/lib/validations/coach-prefs", () => ({ parseCoachPrefs: vi.fn() })); +vi.mock("@/lib/sse/create-stream", () => ({ createSseStream: vi.fn() })); + +import { POST, GET } from "../route"; + +type Envelope = { + data: unknown; + error: string | null; + status: number; + meta?: { errorCode?: string; module?: string }; +}; +const post = POST as unknown as (req: Request) => Promise; +const get = GET as unknown as (req: Request) => Promise; + +const disabledResponse: Envelope = { + data: null, + error: 'Module "coach" is not enabled', + status: 403, + meta: { errorCode: "module.disabled", module: "coach" }, +}; + +function chatPostReq(): Request { + return new Request("http://localhost/api/insights/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hi" }), + }); +} + +function listReq(): Request { + return new Request("http://localhost/api/insights/chat", { method: "GET" }); +} + +describe("coach chat module gate", () => { + beforeEach(() => { + vi.clearAllMocks(); + requireAssistantSurface.mockImplementation(async () => { + throw new AssistantSentinel("reached assistant surface"); + }); + }); + + it("POST returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + + const res = await post(chatPostReq()); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("module.disabled"); + expect(res.meta?.module).toBe("coach"); + expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); + // The gate short-circuits before the legacy assistant flag. + expect(requireAssistantSurface).not.toHaveBeenCalled(); + }); + + it("POST falls through to the assistant flag when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + + await expect(post(chatPostReq())).rejects.toBeInstanceOf(AssistantSentinel); + expect(requireAssistantSurface).toHaveBeenCalledWith("coach"); + }); + + it("GET returns 403 module.disabled when Coach is disabled", async () => { + requireModuleEnabled.mockResolvedValue({ + enabled: false, + response: disabledResponse, + }); + + const res = await get(listReq()); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("module.disabled"); + expect(requireModuleEnabled).toHaveBeenCalledWith("u1", "coach"); + expect(requireAssistantSurface).not.toHaveBeenCalled(); + }); + + it("GET reaches 200 when Coach is enabled", async () => { + requireModuleEnabled.mockResolvedValue({ enabled: true }); + requireAssistantSurface.mockResolvedValue(undefined); + + const res = await get(listReq()); + + expect(res.status).toBe(200); + expect(requireAssistantSurface).toHaveBeenCalledWith("coach"); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 73bf1c728..df06d2c80 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -35,6 +35,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { @@ -151,6 +152,10 @@ function buildHistoryWindow( async function handleChatRequest(request: NextRequest): Promise { const auth = await requireAuth(); + // v1.18.0 — two-layer module gate (operator availability + per-user + // disableCoach) on top of the legacy assistant flag. 403 module.disabled. + const gate = await requireModuleEnabled(auth.user.id, "coach"); + if (!gate.enabled) return gate.response; // v1.4.31 — operator can disable the Coach surface app-wide. // Throws AssistantDisabledError → apiHandler returns 403 + // `errorCode: "assistant.disabled.coach"` per the iOS contract. @@ -687,6 +692,9 @@ export const POST = apiHandler(handleChatRequest); */ export const GET = apiHandler(async (request: NextRequest) => { const auth = await requireAuth(); + // v1.18.0 — same two-layer module gate as the SSE POST. + const gate = await requireModuleEnabled(auth.user.id, "coach"); + if (!gate.enabled) return gate.response; // v1.4.31 — same gate as the SSE POST. Hiding the rail when // the operator has disabled Coach matches the FAB suppression // on the client. diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 3899fa947..04677474c 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -11,6 +11,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// ride through; the off → 403 coverage lives in the route-gate +// inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -41,6 +51,8 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { apiError } from "@/lib/api-response"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { @@ -50,6 +62,7 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); // v1.15.20 — default to an allowing analytics-read budget. vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({ allowed: true, @@ -147,4 +160,25 @@ describe("GET /api/insights/correlations", () => { // The limited request never reaches the series reads. expect(prisma.measurement.findMany).not.toHaveBeenCalled(); }); + + // v1.18.0 (B2) — the route now also requires the `insights` module. + it("returns 403 + module.disabled when the insights module is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: apiError('Module "insights" is not enabled', 403, { + errorCode: "module.disabled", + module: "insights", + }), + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { + meta?: { errorCode?: string; module?: string }; + }; + expect(body.meta?.errorCode).toBe("module.disabled"); + expect(body.meta?.module).toBe("insights"); + // The disabled-module request never reaches the series reads. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index c7b1513cb..3ff9b0da6 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -20,6 +20,7 @@ import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -73,6 +74,9 @@ export const GET = apiHandler(async () => { return apiError("Too many analytics requests. Please retry later.", 429); } + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + // Operator can hide the correlation surface entirely. await requireAssistantSurface("correlations"); diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts index f6f8802a1..8d75f48d5 100644 --- a/src/app/api/insights/derived/__tests__/route.test.ts +++ b/src/app/api/insights/derived/__tests__/route.test.ts @@ -13,6 +13,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()` (in addition to the existing per-metric sleep/recovery +// gate). Mock it default-enabled so the existing assertions ride through; +// the off → 403 coverage lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/insights/derived/batch/__tests__/route.test.ts b/src/app/api/insights/derived/batch/__tests__/route.test.ts index 9695e352b..c3469b575 100644 --- a/src/app/api/insights/derived/batch/__tests__/route.test.ts +++ b/src/app/api/insights/derived/batch/__tests__/route.test.ts @@ -61,7 +61,24 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => { }; }); +// v1.18.0 — the route resolves the per-user module map to drop disabled- +// module derived scores; stub it so the batch tests stay focused (default: +// every module enabled) and the profile-read count isn't perturbed. +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(), + // v1.18.0 — the route gates on the `insights` module after + // `requireAuth()`. Default-enabled so the existing per-metric + // resolveModuleMap assertions ride through; the off → 403 coverage + // lives in the route-gate inventory test. + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + }; +}); + import { GET } from "../route"; +import { resolveModuleMap, requireModuleEnabled } from "@/lib/modules/gate"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; @@ -96,6 +113,8 @@ beforeEach(() => { __resetAllCachesForTests(); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(resolveModuleMap).mockResolvedValue({} as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: new Date("1986-01-01"), gender: "MALE", diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index 271d88783..ae7f56332 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -39,6 +39,8 @@ import { } from "@/lib/insights/derived"; import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { DERIVED_MODULE } from "../route"; export const dynamic = "force-dynamic"; @@ -113,6 +115,8 @@ function parseBatchItems(csv: string): { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); // Per-user limiter, same posture as the compliance routes: the cold @@ -201,17 +205,35 @@ export const GET = apiHandler(async (request: NextRequest) => { annotate, ); + // v1.18.0 — module gate. A derived score native to a toggleable module + // (sleep / recovery, per DERIVED_MODULE — the SAME map the single route + // uses, so the two cannot drift) must not reach the client when that + // module is disabled for the user. Resolved per request (NOT folded into + // the SWR key) and applied to a NEW object so the cached map is never + // mutated; a module toggle takes effect on the next read. + const moduleMap = await resolveModuleMap(user.id); + const gatedMetrics = Object.fromEntries( + Object.entries(map).filter(([key]) => { + const metric = key.includes(":") ? key.slice(0, key.indexOf(":")) : key; + const mod = DERIVED_MODULE[metric as DerivedMetricId]; + return !(mod && moduleMap[mod] === false); + }), + ); + annotate({ action: { name: "insights.derived.batch" }, meta: { requested: items.length, - ok: Object.values(map).filter((entry) => entry.status === "ok").length, + ok: Object.values(gatedMetrics).filter((entry) => entry.status === "ok") + .length, + moduleGated: Object.keys(map).length - Object.keys(gatedMetrics).length, }, }); // `metrics` is a map keyed by the per-request token so the client reads - // back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`). - return apiSuccess({ metrics: map }); + // back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`), + // minus any metric whose owning module the user disabled. + return apiSuccess({ metrics: gatedMetrics }); }); /** diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 8c0f9d0e8..5a008ceca 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -25,6 +25,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { computeDerivedMetric, @@ -37,6 +38,24 @@ import { resolveServerLocale } from "@/lib/i18n/server-locale"; export const dynamic = "force-dynamic"; +/** + * Derived scores that are NATIVE to a toggleable module rather than a core + * vital. SLEEP_SCORE is the sleep module's; the strain / recovery / stress + * trio belongs to the WHOOP/Polar recovery module. READINESS equals the + * computed recovery score (same number, recovery-native), so it is gated on + * the recovery module too. The remaining composite baselines + * (VITALS_BASELINE, HRV_BALANCE, …) read off core vitals and stay open — a + * metric absent from this map carries no module gate. Exported so the batch + * route gates on the SAME map and the two routes cannot drift. + */ +export const DERIVED_MODULE: Partial> = { + SLEEP_SCORE: "sleep", + RECOVERY_SCORE: "recovery", + STRAIN_SCORE: "recovery", + STRESS_SCORE: "recovery", + READINESS: "recovery", +}; + // Closed enum derived from the registry so the route + registry cannot // drift — an id added to the registry is accepted automatically, an // unknown id 422s rather than silently resolving to no metric. @@ -52,6 +71,9 @@ const derivedQuerySchema = z.object({ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + // v1.15.20 — shared analytics-read budget (generous; caps runaway loops). const rl = await checkAnalyticsReadRateLimit(user.id); if (!rl.allowed) { @@ -73,6 +95,15 @@ export const GET = apiHandler(async (request: NextRequest) => { } const metric = parsed.data.metric as DerivedMetricId; + // Per-domain gate: a derived score owned by a toggleable module + // (sleep / recovery) is refused with a 403 module.disabled envelope when + // the account has that module turned off. Core-vital composites stay open. + const moduleKey = DERIVED_MODULE[metric]; + if (moduleKey) { + const gate = await requireModuleEnabled(user.id, moduleKey); + if (!gate.enabled) return gate.response; + } + // Profile read once via the shared loader (the same one the batch route // and the nightly score jobs use), passed into the pure compute function // — never re-fetched per metric. diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index 32f54214c..c4922b321 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts index 37aab757f..208be55a6 100644 --- a/src/app/api/insights/metric-status/__tests__/route.test.ts +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -7,6 +7,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()` (in addition to the existing per-metric sleep/glucose/ +// recovery gate). Mock it default-enabled so the existing assertions ride +// through; the off → 403 coverage lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -43,6 +53,7 @@ vi.mock("@/lib/insights/metric-status", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { generateMetricStatus } from "@/lib/insights/metric-status"; const SESSION_OK = { @@ -60,6 +71,7 @@ function makeReq(metric?: string): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); }); describe("GET /api/insights/metric-status", () => { diff --git a/src/app/api/insights/metric-status/route.ts b/src/app/api/insights/metric-status/route.ts index 3887c1eca..e6b62286e 100644 --- a/src/app/api/insights/metric-status/route.ts +++ b/src/app/api/insights/metric-status/route.ts @@ -27,6 +27,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; import { generateMetricStatus, resolveMetricStatusLocale, @@ -38,6 +39,20 @@ import { export const dynamic = "force-dynamic"; +/** + * The handful of generic metrics whose assessment belongs to a toggleable + * module rather than a core vital. Everything else this route serves + * (resting heart rate, body composition, gait, environmental exposure, …) + * is a core/always-on signal and stays ungated. A metric absent from this + * map is core by construction. + */ +const METRIC_MODULE: Partial> = { + SLEEP_DURATION: "sleep", + BREATHING_DISTURBANCES: "sleep", + BLOOD_GLUCOSE: "glucose", + CARDIO_RECOVERY: "recovery", +}; + // Closed enum derived from the registry so the route + registry cannot // drift — an id added to the registry is accepted automatically, and an // unknown id 422s rather than silently resolving to no card. @@ -47,6 +62,8 @@ const metricQuerySchema = z.object({ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const parsed = metricQuerySchema.safeParse({ @@ -61,6 +78,17 @@ export const GET = apiHandler(async (request: NextRequest) => { } const metric = parsed.data.metric as MetricStatusMetricId; + // Per-domain gate: a metric that belongs to a toggleable module + // (sleep / glucose / recovery) is refused with a 403 module.disabled + // envelope when the account has that module turned off. Core vitals, + // body composition, gait and environmental signals carry no module and + // stay open. + const moduleKey = METRIC_MODULE[metric]; + if (moduleKey) { + const gate = await requireModuleEnabled(user.id, moduleKey); + if (!gate.enabled) return gate.response; + } + const localeParam = request.nextUrl.searchParams.get("locale"); const resolved = await resolveServerLocale({ request, diff --git a/src/app/api/insights/mood-status/__tests__/route.test.ts b/src/app/api/insights/mood-status/__tests__/route.test.ts new file mode 100644 index 000000000..eaf859da7 --- /dev/null +++ b/src/app/api/insights/mood-status/__tests__/route.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + user: { findUnique: vi.fn() }, + cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the heavy generator so the route test stays isolated. +vi.mock("@/lib/insights/mood-status", () => ({ + generateMoodStatusForUser: vi.fn(async () => ({ + hasProvider: true, + text: "ok", + cached: true, + updatedAt: new Date().toISOString(), + })), + resolveMoodStatusLocale: () => "en", +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest( + new URL("http://localhost/api/insights/mood-status"), + ); +} + +/** Persisted module-preferences blob for the gated user row. */ +function userRow(modulePreferencesJson: unknown) { + return { + gender: null, + disableCoach: false, + modulePreferencesJson, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); +}); + +describe("GET /api/insights/mood-status — module gate", () => { + it("200s when the mood module is enabled (default-on, no blob)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow(null) as never, + ); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { text: string } | null }; + expect(body.data?.text).toBe("ok"); + expect(generateMoodStatusForUser).toHaveBeenCalled(); + }); + + it("403s + module.disabled when mood is turned off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue( + userRow({ mood: false }) as never, + ); + const res = await callGet(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { + meta?: { errorCode?: string; module?: string }; + }; + expect(body.meta?.errorCode).toBe("module.disabled"); + expect(body.meta?.module).toBe("mood"); + expect(generateMoodStatusForUser).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq()); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts index c47ff442a..fcf72828d 100644 --- a/src/app/api/insights/mood-status/route.ts +++ b/src/app/api/insights/mood-status/route.ts @@ -8,11 +8,21 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + // Per-domain gate: the mood module must be enabled for this account + // before the mood AI-status surface is served (mirrors the cycle gate). + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/insights/narrative/route.ts b/src/app/api/insights/narrative/route.ts index ff7909ed4..266dfdf04 100644 --- a/src/app/api/insights/narrative/route.ts +++ b/src/app/api/insights/narrative/route.ts @@ -18,6 +18,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { readPeriodNarrative } from "@/lib/insights/narrative/period-narrative-generate"; import { PERIOD_DAYS, type NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; @@ -39,6 +40,8 @@ function narrowLocale(locale: string): "de" | "en" { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const parsed = narrativeQuerySchema.safeParse({ diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts index a4852bc19..9e5c76c98 100644 --- a/src/app/api/insights/pregenerate/__tests__/route.test.ts +++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts @@ -7,6 +7,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// ride through; the off → 403 coverage lives in the route-gate inventory +// test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -42,6 +52,7 @@ vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ import { POST } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; const SESSION_OK = { @@ -60,6 +71,7 @@ function makeReq(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); }); describe("POST /api/insights/pregenerate", () => { diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts index ec5e41380..411803cc4 100644 --- a/src/app/api/insights/pregenerate/route.ts +++ b/src/app/api/insights/pregenerate/route.ts @@ -26,6 +26,7 @@ import { NextRequest } from "next/server"; import { apiSuccess, apiError } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -38,6 +39,8 @@ const WARM_WINDOW_MS = 3 * 60 * 1000; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; // Gate on the same surface as the read-only status routes: this warms // the assessment cards (the `insightStatus` surface), not the Coach. // A user with assessments enabled but Coach disabled can still warm. diff --git a/src/app/api/insights/pulse-status/route.ts b/src/app/api/insights/pulse-status/route.ts index 72f67afec..dd22e87f8 100644 --- a/src/app/api/insights/pulse-status/route.ts +++ b/src/app/api/insights/pulse-status/route.ts @@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/insights/rhythm-events/__tests__/route.test.ts b/src/app/api/insights/rhythm-events/__tests__/route.test.ts index fbc29ca93..e2d0860b0 100644 --- a/src/app/api/insights/rhythm-events/__tests__/route.test.ts +++ b/src/app/api/insights/rhythm-events/__tests__/route.test.ts @@ -18,6 +18,16 @@ vi.mock("@/lib/db", () => ({ }, })); +// v1.18.0 — the route now resolves the `insights` module gate after +// `requireAuth()`. Mock it default-enabled so the existing assertions +// ride through; the off → 403 coverage lives in the route-gate inventory +// test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts index 9d45cb7d0..ca98cc69d 100644 --- a/src/app/api/insights/rhythm-events/route.ts +++ b/src/app/api/insights/rhythm-events/route.ts @@ -28,6 +28,7 @@ import { apiSuccess } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -42,6 +43,8 @@ const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[] export const GET = apiHandler(async () => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const rows = await prisma.measurement.findMany({ diff --git a/src/app/api/insights/weight-status/route.ts b/src/app/api/insights/weight-status/route.ts index 511ade38c..124eaff0f 100644 --- a/src/app/api/insights/weight-status/route.ts +++ b/src/app/api/insights/weight-status/route.ts @@ -8,11 +8,14 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; await requireAssistantSurface("insightStatus"); const localeParam = request.nextUrl.searchParams.get("locale"); diff --git a/src/app/api/mood/analytics/__tests__/route.test.ts b/src/app/api/mood/analytics/__tests__/route.test.ts index 3884ddcdd..07dc06101 100644 --- a/src/app/api/mood/analytics/__tests__/route.test.ts +++ b/src/app/api/mood/analytics/__tests__/route.test.ts @@ -37,6 +37,12 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +// v1.18.0 — pin the mood module gate to "enabled" so this suite stays +// focused on the mood-series engine; the gate is covered elsewhere. +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/app/api/mood/analytics/route.ts b/src/app/api/mood/analytics/route.ts index 5774a1095..30b5bbc0a 100644 --- a/src/app/api/mood/analytics/route.ts +++ b/src/app/api/mood/analytics/route.ts @@ -6,12 +6,19 @@ import { buildMoodDailySeries, type MoodDailySeries, } from "@/lib/analytics/mood-series"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // Per-domain gate: the mood daily series feeds the mood module's + // analysis surfaces (sparkline + mood page), so it gates on the mood + // module (mirrors `/api/insights/mood-status`). Disabled ⇒ 403. + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + // v1.17.1 — single mood engine: the route and the dashboard snapshot // both read through `buildMoodDailySeries`, so the dashboard tile, the // insights mood sparkline, and the iOS client all see the same number. diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts index c6a1b2770..95d37c5c9 100644 --- a/src/app/api/mood/insights/route.ts +++ b/src/app/api/mood/insights/route.ts @@ -6,6 +6,7 @@ import { fetchMoodAggregates, type MoodAggregates, } from "@/lib/insights/mood-aggregates"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; @@ -28,6 +29,12 @@ export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // Per-domain gate: the mood-insights aggregate surface only serves the + // mood module's analysis page, so it must be gated on the mood module + // (mirrors `/api/insights/mood-status`). Disabled ⇒ 403 module.disabled. + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + const result = await cachedSwr( caches.moodInsights as ServerCache, user.id, diff --git a/src/app/api/sleep/night/__tests__/route.test.ts b/src/app/api/sleep/night/__tests__/route.test.ts index aef6583ef..519e795d5 100644 --- a/src/app/api/sleep/night/__tests__/route.test.ts +++ b/src/app/api/sleep/night/__tests__/route.test.ts @@ -21,6 +21,13 @@ vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(async () => null), })); +// v1.18.0 — pin the sleep module gate to "enabled" so this suite stays +// focused on the hypnogram read; the gate itself is covered by the module +// gate + route-gate-inventory suites. +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/app/api/sleep/night/route.ts b/src/app/api/sleep/night/route.ts index 29d8874f0..cded37eef 100644 --- a/src/app/api/sleep/night/route.ts +++ b/src/app/api/sleep/night/route.ts @@ -38,6 +38,7 @@ import { } from "@/lib/analytics/sleep-night"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { requireModuleEnabled } from "@/lib/modules/gate"; /** Sleep is read row-per-stage, so the window is bounded to one year. */ const SLEEP_NIGHT_MAX_DAYS = 365; @@ -93,6 +94,11 @@ function serializeSession(s: SleepSession) { export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + // Per-domain gate: the hypnogram read serves only the sleep module's + // view surfaces, so it gates on the sleep module. Disabled ⇒ 403. + const gate = await requireModuleEnabled(user.id, "sleep"); + if (!gate.enabled) return gate.response; + const parsed = querySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams), ); diff --git a/src/app/api/sleep/rhythm/__tests__/route.test.ts b/src/app/api/sleep/rhythm/__tests__/route.test.ts index 85d94cdc5..a817425b4 100644 --- a/src/app/api/sleep/rhythm/__tests__/route.test.ts +++ b/src/app/api/sleep/rhythm/__tests__/route.test.ts @@ -22,6 +22,12 @@ vi.mock("@/lib/rollups/measurement-read", () => ({ loadUserSourcePriority: vi.fn(async () => null), })); +// v1.18.0 — pin the sleep module gate to "enabled" so this suite stays +// focused on the sleep-rhythm read; the gate is covered elsewhere. +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); + vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/app/api/sleep/rhythm/route.ts b/src/app/api/sleep/rhythm/route.ts index 8eb6c1502..50623ac82 100644 --- a/src/app/api/sleep/rhythm/route.ts +++ b/src/app/api/sleep/rhythm/route.ts @@ -22,10 +22,17 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm"; +import { requireModuleEnabled } from "@/lib/modules/gate"; export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // Per-domain gate: the sleep-rhythm read serves only the sleep module's + // view surfaces (Sleep page + iOS), so it gates on the sleep module. + // Disabled ⇒ 403 module.disabled. + const gate = await requireModuleEnabled(user.id, "sleep"); + if (!gate.enabled) return gate.response; + const rhythm = await buildSleepRhythm(user.id); annotate({ diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index 67b6568f7..8a8654e13 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -19,6 +19,18 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +// v1.18.0 — the GET alias resolves the module map. Stub it so these +// tests assert the projection wiring without standing up the gate's +// own DB round-trips (operator availability + assistant flags + cycle). +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn().mockResolvedValue({ + sleep: true, + glucose: true, + cycle: false, + coach: true, + }), +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -31,6 +43,14 @@ vi.mock("next/headers", () => ({ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { resolveModuleMap } from "@/lib/modules/gate"; + +const MODULE_MAP = { + sleep: true, + glucose: true, + cycle: false, + coach: true, +} as const; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -39,6 +59,7 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -108,6 +129,36 @@ describe("GET /api/user/profile", () => { expect(body.data.insurerIkNumber).toBe("101234567"); expect(body.data.insurerName).toBe("Example Insurer"); }); + + // v1.18.0 — the alias the iOS app reads must carry the resolved module + // map, matching the /api/auth/me projection so the client gets module + // flags from the endpoint it actually fetches. + it("includes the resolved module map", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + username: "testuser", + displayName: null, + email: null, + dateOfBirth: null, + gender: null, + heightCm: null, + locale: null, + timezone: "Europe/Berlin", + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumberEncrypted: null, + } as never); + + const res = await callGet(makeGetReq()); + expect(res.status).toBe(200); + expect(resolveModuleMap).toHaveBeenCalledWith("user-1"); + const body = (await res.json()) as { + data: { modules: Record }; + }; + expect(body.data.modules).toEqual(MODULE_MAP); + }); }); describe("PATCH /api/user/profile", () => { diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index cf799b456..095ade7e3 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context"; import { applyProfileUpdate } from "@/lib/auth/profile-update"; import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; +import { resolveModuleMap } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; @@ -25,25 +26,35 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "user.profile.get" } }); - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - username: true, - displayName: true, - email: true, - dateOfBirth: true, - gender: true, - heightCm: true, - locale: true, - timezone: true, - timeFormat: true, - moodReminderEnabled: true, - fullName: true, - insurerName: true, - insurerIkNumber: true, - insuranceNumberEncrypted: true, - }, - }); + // Overlap the profile-row read with the module-map resolve (a separate + // memoised Postgres round-trip) — both feed the same response and have + // no ordering dependency. + const [dbUser, modules] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { + username: true, + displayName: true, + email: true, + dateOfBirth: true, + gender: true, + heightCm: true, + locale: true, + timezone: true, + timeFormat: true, + moodReminderEnabled: true, + fullName: true, + insurerName: true, + insurerIkNumber: true, + insuranceNumberEncrypted: true, + }, + }), + // v1.18.0 — resolved module enable/disable map, identical to the + // projection `GET /api/auth/me` returns. The iOS app reads this alias + // endpoint, so it must carry the same server-authoritative flags; a + // `false` key means the module surface disappears end-to-end. + resolveModuleMap(user.id), + ]); // v1.7.0 — decrypt the KVNR fail-soft so a key-rotation gap never 500s // the profile fetch. @@ -71,6 +82,10 @@ export const GET = apiHandler(async () => { insurerName: dbUser?.insurerName ?? null, insurerIkNumber: dbUser?.insurerIkNumber ?? null, insuranceNumber, + // v1.18.0 — module enable/disable map, matching the /api/auth/me + // projection exactly so the native client gets module flags from the + // endpoint it actually reads. + modules, }); }); diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index d2b7141a9..d4f55e537 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -29,6 +29,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; +import { requireModuleEnabled } from "@/lib/modules/gate"; type RouteParams = { params: Promise<{ id: string }> }; @@ -39,6 +40,10 @@ export const GET = apiHandler( annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } }); + // v1.18.0 B1 — gate the detail surface behind the workouts module. + const gate = await requireModuleEnabled(user.id, "workouts"); + if (!gate.enabled) return gate.response; + const row = await prisma.workout.findUnique({ where: { id }, include: { diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index de21ca911..4d360b71c 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -46,9 +46,19 @@ vi.mock("next/headers", () => ({ })), })); +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => ({})), + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + }; +}); + import { GET } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -113,6 +123,10 @@ describe("GET /api/workouts — canonical dedup", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, } as never); + // clearAllMocks wipes the factory default; re-seed the gate to pass. + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: true, + } as never); }); it("collapses an APPLE_HEALTH + WITHINGS twin to the Apple row", async () => { @@ -139,6 +153,20 @@ describe("GET /api/workouts — canonical dedup", () => { expect(res.status).toBe(401); }); + it("returns 403 module.disabled when the workouts module is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: new Response( + JSON.stringify({ data: null, error: "Module disabled" }), + { status: 403 }, + ), + } as never); + const res = await GET(makeRequest()); + expect(res.status).toBe(403); + // The list query never runs once the gate refuses. + expect(prisma.workout.findMany).not.toHaveBeenCalled(); + }); + it("filters by ownership — only the caller's rows enter the picker", async () => { // The mock returns whatever the route asked Prisma for; the route's // `where: { userId: user.id }` filter is what enforces ownership. diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 683a53c5d..f3de4f6d1 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -44,9 +44,19 @@ vi.mock("next/headers", () => ({ })), })); +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => ({})), + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), + }; +}); + import { GET } from "../[id]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -94,6 +104,10 @@ describe("GET /api/workouts/{id}", () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, } as never); + // clearAllMocks wipes the factory default; re-seed the gate to pass. + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: true, + } as never); }); it("returns the workout with iOS-contract field names", async () => { @@ -123,6 +137,20 @@ describe("GET /api/workouts/{id}", () => { expect(res.status).toBe(401); }); + it("returns 403 module.disabled when the workouts module is off", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ + enabled: false, + response: new Response( + JSON.stringify({ data: null, error: "Module disabled" }), + { status: 403 }, + ), + } as never); + const res = await GET(makeRequest(), makeParams("w-1")); + expect(res.status).toBe(403); + // The detail query never runs once the gate refuses. + expect(prisma.workout.findUnique).not.toHaveBeenCalled(); + }); + it("returns 404 when the row belongs to another user", async () => { vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ ...BASE_ROW, diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index 4fe739e6a..098a99990 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -55,6 +55,7 @@ import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { requireModuleEnabled } from "@/lib/modules/gate"; const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; @@ -171,6 +172,13 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); annotate({ action: { name: "workouts.list" } }); + // v1.18.0 B1 — the workouts surface is a toggleable module. A disabled + // account must not be able to read the list even with a valid Bearer + // token. The batch INGEST route stays ungated so synced data still + // lands while the surface is hidden. + const gate = await requireModuleEnabled(user.id, "workouts"); + if (!gate.enabled) return gate.response; + const { searchParams } = new URL(request.url); const limit = Math.min( Math.max( diff --git a/src/app/insights/coach/page.tsx b/src/app/coach/page.tsx similarity index 82% rename from src/app/insights/coach/page.tsx rename to src/app/coach/page.tsx index a601d6f9b..77e3efe36 100644 --- a/src/app/insights/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -20,13 +20,14 @@ import { useDisableCoach } from "@/hooks/use-disable-coach"; * thin chrome wrapper that supplies a "minimize back to drawer" * control where the drawer supplies "maximize" + "close". * - * The route lives inside `app/insights/layout.tsx` (sticky tab strip + - * mobile FAB) and the authenticated `` (which provides the - * `` the minimize control reopens the drawer - * through), so the page inherits the same Coach feature gating as every - * other Insights surface. When the operator's master flag is off or the - * user opted out, the page redirects back to `/insights` rather than - * painting a dead chat shell. + * v1.18.0 — the route is a standalone top-level page (`/coach`), no longer + * nested under `app/insights/layout.tsx`, so it renders in the standard + * page chrome WITHOUT the Insights tab strip. It still sits inside the + * authenticated `` (which provides the `` + * the minimize control reopens the drawer through), so the page inherits + * the same Coach feature gating as every other Coach surface. When the + * operator's master flag is off or the user opted out, the page redirects + * back to `/insights` rather than painting a dead chat shell. */ export default function CoachPage() { const { t } = useTranslations(); diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx index dbdbe72d1..680ea2223 100644 --- a/src/app/cycle/page.tsx +++ b/src/app/cycle/page.tsx @@ -10,18 +10,21 @@ import { CycleView } from "@/components/cycle/cycle-view"; /** * v1.15.0 — the cycle vertical entry. * - * Gated on `cycleTrackingEnabled` from `GET /api/auth/me` (resolved - * server-side from gender + the per-user opt-in). An unauthenticated visitor - * is bounced to login; an authenticated account without the feature enabled - * is bounced home (the nav entry is already hidden for them, so this only - * catches a direct URL hit). Every `/api/cycle/*` route also enforces the - * gate server-side, so this is a UX redirect, not the security boundary. + * Gated on the resolved `modules.cycle` flag from `GET /api/auth/me` + * (v1.18.0 — the per-user toggle AND the operator server-wide kill-switch; + * the operator-unaware `cycleTrackingEnabled` field is NOT used here so an + * operator-disabled instance bounces a direct URL hit too). An + * unauthenticated visitor is bounced to login; an authenticated account + * without the module enabled is bounced home (the nav entry is already + * hidden for them, so this only catches a direct URL hit). Every + * `/api/cycle/*` route also enforces the gate server-side, so this is a UX + * redirect, not the security boundary. */ export default function CyclePage() { const { user, isLoading, isAuthenticated } = useAuth(); const router = useRouter(); - const enabled = user?.cycleTrackingEnabled === true; + const enabled = user?.modules?.cycle !== false; useEffect(() => { if (isLoading) return; diff --git a/src/app/globals.css b/src/app/globals.css index 739ebc1ca..46b5f1dc9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -58,6 +58,7 @@ --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); + --color-tab-inactive: var(--tab-inactive); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); @@ -171,6 +172,11 @@ --secondary-foreground: #f8f8f2; --muted: #44475a; --muted-foreground: #9aa3b3; + /* Inactive tab-trigger text. `--muted-foreground` (#9aa3b3) on the + * muted (#44475a) tab-strip reads 3.6:1 — below WCAG AA. This brighter + * tone clears 4.5:1 on the muted strip while staying within the Dracula + * palette; the active trigger keeps `--foreground`. */ + --tab-inactive: #b3bccd; /* NativeSelect chevron — a data-URI background-image cannot resolve * `currentColor` (it falls back to black per the SVG spec), so the * muted-foreground value is baked into the stroke per theme. Keep in @@ -275,6 +281,9 @@ --secondary-foreground: #1f1f1f; --muted: #dcdeef; --muted-foreground: #5b6273; + /* Inactive tab-trigger text — the light muted-foreground already clears + * AA (4.58:1) on the light muted strip, so the token mirrors it. */ + --tab-inactive: #5b6273; /* Light-theme counterpart of the NativeSelect chevron (see dark block). */ --native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b6273' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); --accent: #dcdeef; diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index e753ad589..22b2c3a9b 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { Droplet } from "lucide-react"; +import { Droplet, Loader2 } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; import { GlucoseClinicalPanel } from "@/components/insights/glucose/glucose-clinical-panel"; import { useAuth } from "@/hooks/use-auth"; +import { useModulePageGuard } from "@/hooks/use-module-page-guard"; import { useTranslations } from "@/lib/i18n/context"; import { resolveGlucoseUnit } from "@/lib/glucose"; @@ -29,9 +30,19 @@ const MGDL_PER_MMOL = 18.0182; export default function InsightsBlutzuckerPage() { const { user } = useAuth(); const { t } = useTranslations(); + const { ready } = useModulePageGuard("glucose"); const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null); const isMmol = glucoseUnit === "mmol/L"; + // v1.18.0 B1 — bounce a direct URL hit on a disabled-glucose account. + if (!ready) { + return ( +
+ +
+ ); + } + return ( : undefined} + // v1.18.0 — gate on the resolved `modules.cycle` flag (per-user toggle + // AND the operator server-wide kill-switch) so an operator-off + // instance never renders the ring nor fires its calendar read. + extraTile={ + user?.modules?.cycle === true ? : undefined + } // v1.15.5 — when the cycle ring is shown it TAKES the Strain slot: // hide Strain so the strip stays compact instead of growing a sixth // tile. Strain stays visible for non-cycle accounts. - hideStrain={user?.cycleTrackingEnabled === true} + hideStrain={user?.modules?.cycle === true} /> ), "daily-briefing": flags.briefing ? ( diff --git a/src/app/insights/recovery/page.tsx b/src/app/insights/recovery/page.tsx index 31125cec1..df6a268b7 100644 --- a/src/app/insights/recovery/page.tsx +++ b/src/app/insights/recovery/page.tsx @@ -1,6 +1,9 @@ "use client"; +import { Loader2 } from "lucide-react"; + import { useTranslations } from "@/lib/i18n/context"; +import { useModulePageGuard } from "@/hooks/use-module-page-guard"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { RecoverySection } from "@/components/insights/recovery/recovery-section"; @@ -20,6 +23,16 @@ import { RecoverySection } from "@/components/insights/recovery/recovery-section */ export default function InsightsRecoveryPage() { const { t } = useTranslations(); + const { ready } = useModulePageGuard("recovery"); + + // v1.18.0 B1 — bounce a direct URL hit on a disabled-recovery account. + if (!ready) { + return ( +
+ +
+ ); + } return ( + +
+ ); + } + if (isEmpty) { return ( + +
+ ); + } + return ( + + + ); + } + return ( JSX.Element | null > = { account: AccountSection, + modules: ModulesSection, about: AboutSection, ai: AiSection, + coach: CoachSection, integrations: IntegrationsSection, notifications: NotificationsSection, - reminders: RemindersSection, layout: LayoutSection, dashboard: DashboardSection, insights: InsightsSection, medications: MedicationsSection, mood: MoodSection, thresholds: ThresholdsSection, - sources: () => , api: ApiSection, + gesundheitsakte: GesundheitsakteSection, export: ExportSection, advanced: AdvancedSection, }; diff --git a/src/app/vorsorge/page.tsx b/src/app/vorsorge/page.tsx index f13df7ddc..e123b9828 100644 --- a/src/app/vorsorge/page.tsx +++ b/src/app/vorsorge/page.tsx @@ -24,8 +24,8 @@ export default function VorsorgePage() { } return ( -
- +
+
); } diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx new file mode 100644 index 000000000..c15c72723 --- /dev/null +++ b/src/components/admin/module-availability-section.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { ToggleLeft } from "lucide-react"; +import { toast } from "sonner"; + +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry"; +import type { ModuleKey } from "@/lib/modules/registry"; +import { SettingsToggle } from "./_shared"; +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; + +/** + * v1.18.0 — operator-side panel for server-wide module availability, the + * SECOND layer of the two-layer module model. A module turned off here is + * off for EVERY account regardless of personal preference — mirroring how + * the assistant master flag sits above the per-user coach opt-out. + * + * Core domains (weight, blood pressure, pulse, medications) are NOT module + * keys and never appear here; the gate keeps them always-on structurally. + * + * UX mirrors the assistant-flags matrix: one toggle per module, calm and + * neutral, optimistic flip + server confirm via `useMutation`, with a + * failure toast that reverts by re-invalidating the read query. + */ + +type AvailabilityMap = Record; + +interface ModuleAvailabilityResponse { + availability: AvailabilityMap; +} + +function useModuleAvailability() { + return useQuery({ + queryKey: queryKeys.adminModuleAvailability(), + queryFn: async () => { + return apiGet( + "/api/admin/settings/module-availability", + ); + }, + }); +} + +function useUpdateModuleAvailability() { + const client = useQueryClient(); + const { t } = useTranslations(); + return useMutation({ + mutationFn: async ( + patch: Partial, + ): Promise => { + return apiPatch( + "/api/admin/settings/module-availability", + patch, + ); + }, + onSuccess: (data) => { + client.setQueryData(queryKeys.adminModuleAvailability(), data); + // The resolved `/api/auth/me` module map depends on this operator + // layer; bust it so the operator sees the change within the session. + client.invalidateQueries({ queryKey: queryKeys.authMe() }); + toast.success(t("common.saved")); + }, + onError: (err) => { + toast.error( + err instanceof Error && err.message + ? err.message + : t("admin.modules.saveError"), + ); + client.invalidateQueries({ + queryKey: queryKeys.adminModuleAvailability(), + }); + }, + }); +} + +export function ModuleAvailabilitySection() { + const { t } = useTranslations(); + const { data } = useModuleAvailability(); + const mutation = useUpdateModuleAvailability(); + + const availability = data?.availability; + + return ( +
+ +
+ {MODULE_KEYS.map((key) => { + const def = MODULE_REGISTRY[key]; + // Default-available until the read resolves; an explicit `false` + // from the operator layer is the only thing that turns it off. + const available = availability?.[key] ?? true; + return ( + + mutation.mutate({ [key]: checked } as Partial) + } + disabled={mutation.isPending} + /> + ); + })} +
+
+ ); +} diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 2e20b4761..b7363c57e 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -92,23 +92,28 @@ export function CycleRingTile({ className }: { className?: string }) { const hue = PHASE_HUE[wheel.phase]; return ( - + // `data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise` — the + // keyframe selector is `[data-revealed="true"] .wellness-tile-rise` + // (descendant combinator). Both on the same node never matched, leaving the + // tile stuck at the rise rule's `opacity: 0`. The `contents` wrapper carries + // the flag without adding a layout box. +
+
- + +
); } diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index a13c8fac2..9f3d155e3 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -137,18 +137,21 @@ export function CycleView() { return (
-
-
+
+

{t("cycle.title")}

-

+

{t("cycle.subtitle")}

-
@@ -156,20 +159,29 @@ export function CycleView() {
{/* Wheel + compact phase card — grouped so they stick together. */}
- {/* Wheel — the signature ring on the premium wellness-tile surface. */} + {/* Wheel — the signature ring on the premium wellness-tile surface. + `data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise` + — the keyframe selector is `[data-revealed="true"] + .wellness-tile-rise` (descendant combinator). Both on the same + node never matched, leaving the tile stuck at the rise rule's + `opacity: 0` (invisible but still boxed). The `contents` wrapper + carries the flag without adding a layout box. */}
+
{loading ? (
@@ -213,6 +225,7 @@ export function CycleView() { {t("cycle.ring.firstPeriodCta")} ) : null} +
{/* Phase-education card beneath the wheel — the single instance, diff --git a/src/components/cycle/phase-education-card.tsx b/src/components/cycle/phase-education-card.tsx index 681e2d9ed..eb8e65bcd 100644 --- a/src/components/cycle/phase-education-card.tsx +++ b/src/components/cycle/phase-education-card.tsx @@ -107,18 +107,23 @@ export function PhaseEducationCard({ const phaseName = phase ? t(`cycle.phase.${phase}`) : null; return ( -
+ // `data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise` — the + // keyframe selector is `[data-revealed="true"] .wellness-tile-rise` + // (descendant combinator). Both on the same node never matched, leaving the + // tile stuck at the rise rule's `opacity: 0`. The `contents` wrapper carries + // the flag without adding a layout box. +
+
{/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */}
)} -
+
+
); } diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index 69e6c26dd..e52db6cab 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -109,14 +109,14 @@ export function RecentWorkoutsTile() { >
-
{t("dashboard.recentWorkouts.viewAll")} diff --git a/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx b/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx deleted file mode 100644 index 1f6ffac63..000000000 --- a/src/components/doctor-report/__tests__/doctor-report-dialog.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -/** - * SSR contract tests for the doctor-report export dialog. - * - * Radix UI's `Dialog` portals its content out of the React tree, so - * `renderToStaticMarkup()` returns an empty string for the open dialog. - * Instead we assert the *contract* surfaces the dialog depends on: - * - * - All required i18n keys exist in EN + DE. - * - The component module compiles + imports cleanly. - * - The exported submit-payload type stays the same shape the API - * route reads (`{ startDate, endDate }`). - * - * Behavioural coverage (open → validate → submit → download) lives in - * `e2e/doctor-report.spec.ts`, which drives the dialog through Playwright. - * - * v1.4.15 phase B6. - */ - -const ROOT = join(__dirname, "../../../.."); - -function loadMessages(locale: "en" | "de"): Record { - return JSON.parse( - readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"), - ); -} - -const REQUIRED_KEYS = [ - "title", - "description", - "startLabel", - "endLabel", - "preset90d", - "preset180d", - "preset365d", - "submit", - "cancel", - "errorEndBeforeStart", - "errorRangeTooLong", - "errorInvalidDate", -]; - -describe("doctor-report dialog — i18n contract", () => { - for (const locale of ["en", "de"] as const) { - it(`exposes every doctor-report dialog key in ${locale}.json`, () => { - const messages = loadMessages(locale); - const root = messages.doctorReport as Record | undefined; - expect( - root, - `doctorReport namespace missing in ${locale}.json`, - ).toBeDefined(); - const dialog = root!.dialog as Record | undefined; - expect( - dialog, - `doctorReport.dialog missing in ${locale}.json`, - ).toBeDefined(); - for (const key of REQUIRED_KEYS) { - expect( - dialog![key], - `doctorReport.dialog.${key} missing in ${locale}.json`, - ).toBeTruthy(); - expect(typeof dialog![key]).toBe("string"); - } - }); - - // v1.4.25 W6c — per-section toggle strings. - it(`exposes every doctor-report section-toggle key in ${locale}.json`, () => { - const messages = loadMessages(locale); - const root = messages.doctorReport as Record | undefined; - const sections = root!.sections as Record | undefined; - expect( - sections, - `doctorReport.sections missing in ${locale}.json`, - ).toBeDefined(); - const sectionKeys = [ - "title", - "bp", - "weight", - "pulse", - "bmi", - "mood", - "moodSensitive", - "compliance", - "sleep", - "empty", - ]; - for (const key of sectionKeys) { - expect( - sections![key], - `doctorReport.sections.${key} missing in ${locale}.json`, - ).toBeTruthy(); - expect(typeof sections![key]).toBe("string"); - } - }); - } - - it("EN dialog uses the expected English title + 90d preset", () => { - const en = loadMessages("en"); - const dialog = (en.doctorReport as { dialog: Record }) - .dialog; - expect(dialog.title).toContain("Doctor report"); - expect(dialog.preset90d.toLowerCase()).toContain("90"); - }); - - it("DE dialog uses the German title", () => { - const de = loadMessages("de"); - const dialog = (de.doctorReport as { dialog: Record }) - .dialog; - expect(dialog.title).toContain("Arztbericht"); - expect(dialog.preset90d).toContain("90"); - }); -}); - -describe("doctor-report dialog — module surface", () => { - it("exports `DoctorReportDialog` and a `DoctorReportSubmitPayload` type", async () => { - const mod = await import("../doctor-report-dialog"); - expect(typeof mod.DoctorReportDialog).toBe("function"); - // The type is erased at runtime; verifying export presence is the - // best we can do without `tsc --noEmit` here. - expect("DoctorReportDialog" in mod).toBe(true); - }); -}); diff --git a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx b/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx deleted file mode 100644 index 27c4d2a58..000000000 --- a/src/components/doctor-report/__tests__/doctor-report-section-toggles.test.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/** - * v1.4.43 QoL (M4) — `` strike-through disabled rows. - * - * Pre-fix, sections without data in the current range were filtered - * out of the rendered list, so a user had no signal that "Compliance" - * was a candidate. The fix renders every section in `SECTION_ORDER`; - * empties show with a strike-through label + tooltip explaining why - * they're disabled. Submitting still force-clears their toggle so the - * server never renders an empty section. - * - * Lives in its own SSR file because the `` shell - * portals through Radix and is invisible to `renderToStaticMarkup`; - * here we exercise the toggle list directly through the internal - * `SectionToggles` export. - */ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { - __test_SECTION_ORDER, - SectionToggles, -} from "../doctor-report-dialog"; -import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs"; - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -describe(" v1.4.43 M4 disabled-row behaviour", () => { - it("renders a disabled row + strike-through for sections without data", () => { - const onToggle = vi.fn(); - const html = render( - , - ); - - // Every section row mounts — bp + weight available, others disabled. - for (const key of __test_SECTION_ORDER) { - expect(html).toContain(`data-testid="doctor-report-section-${key}"`); - } - - // Pulse / compliance / sleep / mood / bmi rows carry the disabled - // marker so a future styling tweak can't silently re-enable them. - expect(html).toMatch( - /data-unavailable="true"[\s\S]*?Resting pulse/, - ); - expect(html).toMatch( - /data-unavailable="true"[\s\S]*?Medication adherence/, - ); - expect(html).toMatch(/data-unavailable="true"[\s\S]*?Sleep duration/); - - // v1.4.43 W10 design-M4 — disabled rows render with muted text - // colour (not strike-through, which reads as "deleted" rather - // than "unavailable"). The italic hint line below carries the - // semantic visually. - expect(html).toMatch(/text-muted-foreground[\s\S]*?Sleep duration/); - }); - - it("renders the unavailable-hint tooltip text via the title attribute", () => { - const html = render( - , - ); - // The disabled rows expose a `title=` attribute with the - // unavailable-hint copy so hovering a disabled toggle on desktop - // surfaces the explanation. - expect(html).toContain('title="No data in this period"'); - }); - - it("renders disabled rows with the de locale's hint copy", () => { - const html = render( - , - "de", - ); - // Every-section-empty triggers the wider empty-state hint instead - // of the per-row tooltip. Pin both copies are present in the de - // locale shipping. - expect(html).toContain("Für den gewählten Zeitraum"); - }); - - it("renders disabled rows when at least one section has data", () => { - const html = render( - , - "de", - ); - // German tooltip copy on the disabled rows. - expect(html).toContain('title="Keine Daten in diesem Zeitraum"'); - }); - - it("force-clears the displayed checked state for unavailable rows", () => { - // Even if `prefs.compliance === true` (e.g. previously persisted), - // the displayed switch reads `false` when the section is not - // available in the range. The user can't toggle the disabled - // switch so the desync resolves itself; pin it here. - const html = render( - , - ); - // The compliance row exists and is marked unavailable… - expect(html).toMatch( - /data-unavailable="true"[\s\S]*?Medication adherence/, - ); - // …and the disabled switch is rendered (aria-disabled or - // data-disabled survives in the SSR markup). - expect(html).toContain('data-testid="doctor-report-section-compliance"'); - }); -}); diff --git a/src/components/doctor-report/doctor-report-dialog.tsx b/src/components/doctor-report/doctor-report-dialog.tsx deleted file mode 100644 index 4af0bd050..000000000 --- a/src/components/doctor-report/doctor-report-dialog.tsx +++ /dev/null @@ -1,687 +0,0 @@ -"use client"; - -/** - * Doctor-report export dialog (v1.4.15 phase B6 + v1.4.25 W6c). - * - * Wraps the trigger button so the user picks: - * 1. Reporting period (date range, with three presets). - * 2. Practice / clinic name (optional, persisted between exports). - * 3. Which data sections appear in the PDF (per-user persisted toggles). - * - * Hide-when-empty (the maintainer 2026-05-14): a section's toggle is only shown - * when the selected date range actually has data for it — checking a box - * for an empty section would produce a silently-empty PDF page. The - * availability probe runs on every range change via - * `/api/doctor-report/availability`. - * - * Privacy default: mood is OFF by default per the maintainer — mental-health data - * is opt-in even within a single user's own surface. The API layer - * filters mood out of the report payload server-side when the toggle is - * off, so the data never leaves the DB row. - * - * Defaults: - * - end = today - * - start = today − 90 days - * - sections = persisted prefs OR documented defaults - * - * Validation runs in the client so the dialog can show inline errors - * without bouncing through the server. The server still re-validates via - * `normaliseDateRange()` (silent fallback to last-90-days) — the client - * messages are UX, not security. - */ - -import { useEffect, useMemo, useRef, useState } from "react"; -import { Loader2 } from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { DateInput } from "@/components/ui/date-input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - DEFAULT_DOCTOR_REPORT_PREFS, - type DoctorReportPrefs, -} from "@/lib/validations/doctor-report-prefs"; -import { useTranslations } from "@/lib/i18n/context"; -import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch"; - -const ONE_DAY_MS = 86_400_000; -const MAX_RANGE_DAYS = 730; -const PRACTICE_NAME_MAX_LENGTH = 120; - -/** - * Order in which section toggles render. Matches the order the sections - * appear in the generated PDF so the dialog's mental model lines up with - * the printed artefact. `mood` is intentionally last because its - * privacy-sensitive footnote needs prime visual placement. - */ -const SECTION_ORDER: ReadonlyArray = [ - "bp", - "weight", - "pulse", - "bmi", - "compliance", - "sleep", - "mood", -] as const; - -interface SectionAvailability { - bp: boolean; - weight: boolean; - pulse: boolean; - bmi: boolean; - mood: boolean; - compliance: boolean; - sleep: boolean; - // v1.15.0 — cycle section. Not rendered by this legacy dialog - // (SECTION_ORDER omits it); the flagship health-record export surfaces - // the cycle toggle. Present so `keyof DoctorReportPrefs` indexing - // typechecks. Optional — the availability endpoint may not provide it. - cycle?: boolean; - // v1.17.1 — labs section. Like cycle, not rendered by this legacy dialog; - // the flagship export panel surfaces the toggle. Optional — the - // availability endpoint may not provide it. Present so the - // `keyof DoctorReportPrefs` indexing typechecks. - labs?: boolean; -} - -export interface DoctorReportSubmitPayload { - startDate: string; - endDate: string; - practiceName: string | null; - /** - * Per-section toggles. The caller forwards this to - * `/api/doctor-report` so the aggregator drops disabled sections - * (mood specifically is filtered at the data layer for privacy). - */ - sections: DoctorReportPrefs; -} - -interface DoctorReportDialogProps { - /** Controlled open state. */ - open: boolean; - onOpenChange: (open: boolean) => void; - /** Last-used practice name from `/api/auth/me`; pre-fills the input. */ - defaultPracticeName?: string | null; - /** - * Resolves with the user-confirmed payload. The dialog stays open and - * shows the spinner while the promise is pending; closes on resolve. - */ - onSubmit: (payload: DoctorReportSubmitPayload) => Promise; -} - -/** YYYY-MM-DD in the user's local timezone. */ -function todayIso(): string { - const now = new Date(); - return formatLocalDate(now); -} - -function formatLocalDate(date: Date): string { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, "0"); - const d = String(date.getDate()).padStart(2, "0"); - return `${y}-${m}-${d}`; -} - -function defaultStartIso(): string { - const d = new Date(); - d.setDate(d.getDate() - 90); - return formatLocalDate(d); -} - -function rangeIsoFromLocal( - startDate: string, - endDate: string, -): { - startIso: string; - endIso: string; -} { - // Send dates as ISO timestamps anchored to the local day's start/end - // so an inclusive range `[YYYY-MM-DD, YYYY-MM-DD]` captures everything - // logged on each boundary day. - return { - startIso: new Date(`${startDate}T00:00:00`).toISOString(), - endIso: new Date(`${endDate}T23:59:59.999`).toISOString(), - }; -} - -export function DoctorReportDialog({ - open, - onOpenChange, - defaultPracticeName, - onSubmit, -}: DoctorReportDialogProps) { - const { t } = useTranslations(); - const [startDate, setStartDate] = useState(defaultStartIso); - const [endDate, setEndDate] = useState(todayIso); - const [practiceName, setPracticeName] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - // Per-user section preferences. Loaded lazily when the dialog opens — - // skips a roundtrip on every render of the export card. - const [prefs, setPrefs] = useState( - DEFAULT_DOCTOR_REPORT_PREFS, - ); - const [prefsLoaded, setPrefsLoaded] = useState(false); - - // Section availability for the currently-selected range. `null` = - // "we haven't probed yet" so the section group renders a skeleton row - // rather than flashing an empty state. - const [availability, setAvailability] = useState( - null, - ); - const [availabilityLoading, setAvailabilityLoading] = useState(false); - const availabilityRequestId = useRef(0); - - // Reset on open: re-anchor dates + practice-name to defaults each time - // the dialog appears so a follow-up export doesn't reuse the previous - // custom range. Using the React-recommended "track-the-trigger" pattern - // (store the last observed `open` state and react during render) so the - // reset happens in the render phase rather than a setState-in-effect — - // satisfies the strict `react-hooks/set-state-in-effect` rule. - const [lastOpen, setLastOpen] = useState(false); - if (open && !lastOpen) { - setLastOpen(true); - setStartDate(defaultStartIso()); - setEndDate(todayIso()); - setPracticeName(defaultPracticeName ?? ""); - setError(null); - setSubmitting(false); - } else if (!open && lastOpen) { - setLastOpen(false); - } - - // Load persisted section prefs when the dialog opens for the first - // time. Best-effort: a failure falls back to documented defaults so - // the dialog stays usable even if the preferences endpoint is down. - useEffect(() => { - if (!open || prefsLoaded) return; - let cancelled = false; - apiGet | null | undefined>( - "/api/auth/me/doctor-report-prefs", - { credentials: "include" }, - ) - .then((incoming) => { - if (cancelled) return; - if (incoming && typeof incoming === "object") { - setPrefs({ ...DEFAULT_DOCTOR_REPORT_PREFS, ...incoming }); - } - setPrefsLoaded(true); - }) - .catch(() => { - if (!cancelled) setPrefsLoaded(true); - }); - return () => { - cancelled = true; - }; - }, [open, prefsLoaded]); - - // Re-probe availability whenever the range changes. setState calls - // live inside the promise handlers (microtask-deferred), satisfying - // the strict `react-hooks/set-state-in-effect` rule while keeping the - // loading indicator promptly visible. A generation counter prevents - // a slow earlier response from stomping on a faster later response - // (classic stale-write race). - useEffect(() => { - if (!open) return; - if (!startDate || !endDate) return; - const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate); - const requestId = ++availabilityRequestId.current; - let cancelled = false; - - Promise.resolve() - .then(() => { - if (cancelled) return null; - setAvailabilityLoading(true); - return apiPost( - "/api/doctor-report/availability", - { startDate: startIso, endDate: endIso }, - { credentials: "include" }, - ); - }) - .then((data) => { - if (cancelled || !data) return; - if (requestId !== availabilityRequestId.current) return; - setAvailability(data); - }) - .catch(() => { - // Best-effort — keep the previous availability snapshot. - }) - .finally(() => { - if (!cancelled && requestId === availabilityRequestId.current) { - setAvailabilityLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [open, startDate, endDate]); - - const validation = useMemo(() => { - if (!startDate || !endDate) { - return { - ok: false as const, - key: "doctorReport.dialog.errorInvalidDate", - }; - } - const start = new Date(startDate); - const end = new Date(endDate); - if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { - return { - ok: false as const, - key: "doctorReport.dialog.errorInvalidDate", - }; - } - if (end.getTime() < start.getTime()) { - return { - ok: false as const, - key: "doctorReport.dialog.errorEndBeforeStart", - }; - } - const spanDays = Math.ceil((end.getTime() - start.getTime()) / ONE_DAY_MS); - if (spanDays > MAX_RANGE_DAYS) { - return { - ok: false as const, - key: "doctorReport.dialog.errorRangeTooLong", - }; - } - return { ok: true as const }; - }, [startDate, endDate]); - - function applyPreset(days: number) { - const end = new Date(); - const start = new Date(); - start.setDate(start.getDate() - days); - setStartDate(formatLocalDate(start)); - setEndDate(formatLocalDate(end)); - setError(null); - } - - // v1.4.43 QoL (M4) — pre-fix this memo built the on-screen toggle - // list, so sections without data in the current range vanished - // entirely. The dialog now renders the full `SECTION_ORDER` with - // strike-through disabled rows for the empties, and the submission - // payload still force-clears any unavailable toggle so the server - // never renders an empty section. - - function toggleSection(key: keyof DoctorReportPrefs, value: boolean) { - setPrefs((current) => ({ ...current, [key]: value })); - } - - async function persistPrefs(next: DoctorReportPrefs) { - // Best-effort PUT — a network blip mustn't block PDF generation. The - // current dialog still submits with the chosen `next` value either - // way, so the user gets the PDF they asked for even if persistence - // failed (they'll just see the defaults next time). - try { - await apiPut("/api/auth/me/doctor-report-prefs", next, { - credentials: "include", - }); - } catch { - // ignore - } - } - - async function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - if (!validation.ok) { - setError(t(validation.key)); - return; - } - setSubmitting(true); - setError(null); - try { - const { startIso, endIso } = rangeIsoFromLocal(startDate, endDate); - const trimmedPractice = practiceName.trim(); - - // Compose the section payload: keep the persisted prefs for keys - // that ARE available in the range; force `false` for keys that - // aren't (so the server never tries to render an empty section). - const sections: DoctorReportPrefs = { ...DEFAULT_DOCTOR_REPORT_PREFS }; - for (const key of SECTION_ORDER) { - sections[key] = (availability?.[key] ?? false) && prefs[key] === true; - } - - // Fire-and-forget persistence so the user doesn't wait on it. - void persistPrefs(prefs); - - await onSubmit({ - startDate: startIso, - endDate: endIso, - practiceName: - trimmedPractice.length > 0 - ? trimmedPractice.slice(0, PRACTICE_NAME_MAX_LENGTH) - : null, - sections, - }); - } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error"); - } finally { - setSubmitting(false); - } - } - - return ( - - - - {t("doctorReport.dialog.title")} - - {t("doctorReport.dialog.description")} - - - -
-
-
- - { - setStartDate(e.target.value); - setError(null); - }} - required - /> -
-
- - { - setEndDate(e.target.value); - setError(null); - }} - required - /> -
-
- -
- - - -
- -
- - setPracticeName(e.target.value)} - autoComplete="organization" - /> -
- - {/* ── Section toggles ──────────────────────────────────────── */} - {/* - v1.4.43 QoL (M4) — pass the full `SECTION_ORDER` (not the - availability-filtered `activeSections`) so the user sees a - row for every section that *could* be in the report. Rows - without data in the current range render disabled with a - strike-through label + tooltip, so the user understands - which section is empty and why their PDF has fewer - sections than expected. The submission payload still - force-clears any unavailable toggle, so the server never - renders an empty section. - */} - - - {error && ( -

- {error} -

- )} - - - - - - -
-
- ); -} - -// ────────────────────────────── Subviews ────────────────────────────── - -/** - * Exported alongside `SectionToggles` for the SSR component test. The - * order array is the canonical render order both inside the live - * dialog and inside the test. - */ -export const __test_SECTION_ORDER = [ - "bp", - "weight", - "pulse", - "bmi", - "compliance", - "sleep", - "mood", -] as const satisfies ReadonlyArray; - -interface SectionTogglesProps { - /** - * Sections in render order — always the full `SECTION_ORDER` so the - * dialog shows the user every section that *could* be available, with - * disabled rows for ones that have no data in the current range. - */ - allSections: ReadonlyArray; - availability: SectionAvailability | null; - availabilityLoading: boolean; - prefs: DoctorReportPrefs; - onToggle: (key: keyof DoctorReportPrefs, value: boolean) => void; -} - -const SECTION_LABEL_KEYS: Record = { - bp: "doctorReport.sections.bp", - weight: "doctorReport.sections.weight", - pulse: "doctorReport.sections.pulse", - bmi: "doctorReport.sections.bmi", - mood: "doctorReport.sections.mood", - compliance: "doctorReport.sections.compliance", - sleep: "doctorReport.sections.sleep", - // v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it); - // present so the `keyof DoctorReportPrefs` record stays exhaustive. - cycle: "doctorReport.sections.cycle", - // v1.17.1 — labs section; the flagship export panel drives the toggle, - // present here so the `keyof DoctorReportPrefs` record stays exhaustive. - labs: "doctorReport.sections.labs", -}; - -/** - * Exported only so the SSR component test in - * `__tests__/doctor-report-section-toggles.test.tsx` can render the - * toggle list outside the Radix `Dialog` portal. Treat as internal: - * the public surface is ``. - */ -export function SectionToggles({ - allSections, - availability, - availabilityLoading, - prefs, - onToggle, -}: SectionTogglesProps) { - const { t } = useTranslations(); - - // First open: show a quiet skeleton row instead of the full toggle - // list. Keeps the dialog from "flashing" between empty and populated - // states while the probe is in-flight. - if (availability === null && availabilityLoading) { - return ( -
-

- {t("doctorReport.sections.title")} -

-
- - -
-
- ); - } - - // v1.4.43 QoL (M4) — `availableCount` drives the empty-state hint. - // Pre-fix, an empty range filtered the list down to zero rows; now - // we always render every row with the empties disabled, so the - // empty-state guard kicks in only when *no* section has data. - const availableCount = availability - ? allSections.filter((key) => availability[key]).length - : 0; - - if (availability && availableCount === 0) { - return ( -
-

- {t("doctorReport.sections.title")} -

-

- {t("doctorReport.sections.empty")} -

-
- ); - } - - return ( -
-
-

- {t("doctorReport.sections.title")} -

- {availabilityLoading && ( - - )} -
-
    - {allSections.map((key) => { - const id = `dr-section-${key}`; - // v1.4.43 QoL (M4) — sections without data in the current - // range render disabled with a strike-through label and an - // explanatory tooltip; the user immediately understands - // why their PDF carries fewer sections than expected. - const isAvailable = availability?.[key] ?? false; - const unavailableHint = t("doctorReport.sections.unavailableHint"); - return ( -
  • - -
  • - ); - })} -
-
- ); -} diff --git a/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx b/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx new file mode 100644 index 000000000..66df0b1f3 --- /dev/null +++ b/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.18.0 — `` honours the achievements module + * gate. When the module is disabled the polling query is never enabled, + * so no unlock toast can surface — the engagement surface disappears in + * lock-step with the page, the dashboard tile, and the 403 route. + */ + +let mockUser: { modules?: Record } | null = {}; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: mockUser }), +})); + +const useAchievementsQuery = + vi.fn<(opts?: { enabled?: boolean }) => { data: undefined }>(() => ({ + data: undefined, + })); + +vi.mock("@/lib/queries/use-achievements-query", () => ({ + useAchievementsQuery: (opts?: { enabled?: boolean }) => + useAchievementsQuery(opts), +})); + +const toast = vi.fn(); +vi.mock("sonner", () => ({ toast: (...args: unknown[]) => toast(...args) })); + +import { AchievementUnlockNotifier } from "../achievement-unlock-notifier"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockUser = {}; +}); + +describe(" — achievements module gate", () => { + it("disables the polling query when the module is off", () => { + mockUser = { modules: { achievements: false } }; + expect(render()).toBe(""); + expect(useAchievementsQuery).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it("enables the polling query when the module is on (explicit true)", () => { + mockUser = { modules: { achievements: true } }; + render(); + expect(useAchievementsQuery).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); + + it("enables the query by default when the module key is absent (default-on)", () => { + mockUser = { modules: {} }; + render(); + expect(useAchievementsQuery).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); +}); diff --git a/src/components/gamification/__tests__/recent-achievements-card.test.tsx b/src/components/gamification/__tests__/recent-achievements-card.test.tsx index 10c85fabf..dc538d1f2 100644 --- a/src/components/gamification/__tests__/recent-achievements-card.test.tsx +++ b/src/components/gamification/__tests__/recent-achievements-card.test.tsx @@ -14,8 +14,10 @@ import type { AchievementProgress } from "@/lib/gamification/achievements"; * with their localized title + completedAt date. */ +let mockUser: { modules?: Record } | null = {}; + vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ isAuthenticated: true }), + useAuth: () => ({ isAuthenticated: true, user: mockUser }), })); let mockData: @@ -54,6 +56,7 @@ function render() { beforeEach(() => { vi.clearAllMocks(); mockIsPending = false; + mockUser = {}; }); const baseAchievement: AchievementProgress = { @@ -130,6 +133,41 @@ describe("pickRecentUnlocks", () => { }); }); +describe(" — achievements module gate", () => { + it("renders nothing when the achievements module is disabled", () => { + // v1.18.0 — an explicit `false` for the module hides the dashboard + // tile entirely so the surface disappears in lock-step with the page + // and the 403 route. + mockUser = { modules: { achievements: false } }; + mockData = { + summary: {}, + achievements: [ + { ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" }, + ], + metrics: {}, + }; + expect(render()).toBe(""); + }); + + it("renders the tile when the module is enabled (explicit true)", () => { + mockUser = { modules: { achievements: true } }; + mockData = { + summary: {}, + achievements: [ + { ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" }, + ], + metrics: {}, + }; + expect(render()).toContain('data-slot="recent-achievements-card"'); + }); + + it("renders the tile by default when the module key is absent (default-on)", () => { + mockUser = { modules: {} }; + mockData = { summary: {}, achievements: [], metrics: {} }; + expect(render()).toContain('data-slot="recent-achievements-card"'); + }); +}); + describe("", () => { it("renders a skeleton (no content, no empty state) while the query is pending", () => { // The flash this guards against: before the query settles the card diff --git a/src/components/gamification/achievement-unlock-notifier.tsx b/src/components/gamification/achievement-unlock-notifier.tsx index cc78ea943..e138c97a3 100644 --- a/src/components/gamification/achievement-unlock-notifier.tsx +++ b/src/components/gamification/achievement-unlock-notifier.tsx @@ -3,6 +3,7 @@ import { Sparkles, Trophy } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { useAchievementsQuery } from "@/lib/queries/use-achievements-query"; @@ -56,6 +57,14 @@ export function AchievementUnlockNotifier({ userId, }: AchievementUnlockNotifierProps) { const { t } = useTranslations(); + const { user } = useAuth(); + + // v1.18.0 — the achievements module gate. When the account has the + // module turned off the notifier goes inert: the polling query never + // fires and no unlock toast can surface. Default-on: an absent key reads + // as enabled, so the notifier only stands down on an explicit `false`. + const achievementsEnabled = user?.modules?.achievements !== false; + const storageKey = useMemo(() => `${STORAGE_KEY_PREFIX}:${userId}`, [userId]); const seenIdsRef = useRef>(new Set()); const localStateReadyRef = useRef(false); @@ -70,6 +79,7 @@ export function AchievementUnlockNotifier({ // long-open tabs still surface new unlocks without forcing the card // to re-render on the same cadence. const { data } = useAchievementsQuery({ + enabled: !!user && achievementsEnabled, refetchInterval: 2 * 60 * 1000, }); diff --git a/src/components/gamification/recent-achievements-card.tsx b/src/components/gamification/recent-achievements-card.tsx index fccd23501..df7807644 100644 --- a/src/components/gamification/recent-achievements-card.tsx +++ b/src/components/gamification/recent-achievements-card.tsx @@ -19,6 +19,7 @@ import { type LucideIcon, } from "lucide-react"; import Link from "next/link"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; import { useAchievementsQuery } from "@/lib/queries/use-achievements-query"; @@ -81,6 +82,13 @@ const RECENT_LIMIT = 3; */ export function RecentAchievementsCard() { const { t } = useTranslations(); + const { user, isAuthenticated } = useAuth(); + + // v1.18.0 — the achievements module gate. When the account has the + // module turned off the dashboard tile disappears entirely (the data + // query never fires; the API would 403 anyway). Default-on: an absent + // key reads as enabled, so the tile only hides on an explicit `false`. + const achievementsEnabled = user?.modules?.achievements !== false; // `isPending` (no data yet, fetch in flight or gated) drives the // loading branch. Rendering the empty / content branch before the @@ -89,10 +97,17 @@ export function RecentAchievementsCard() { // content) for the fetch window, then swapped once the real payload // landed. A skeleton holds the card's footprint without committing to // a content shape it may have to retract. - const { data, isPending } = useAchievementsQuery(); + const { data, isPending } = useAchievementsQuery({ + enabled: isAuthenticated && achievementsEnabled, + }); const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT); + // Module off ⇒ render nothing so the tile vanishes from the dashboard. + if (!achievementsEnabled) { + return null; + } + return (
-
{t("achievements.dashboardCard.viewAll")} diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 755b1567c..27980719a 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -94,6 +94,25 @@ describe(" — availability gating (v1.4.27 F19)", () => { expect(html).toContain(">Overview<"); }); + it("always renders the Recovery pill (composite wearable surface, self-gated page)", () => { + // v1.18.0 — Recovery left the left-nav for an Insights pill. It is a + // composite WHOOP / Polar / Oura surface, not a single MeasurementType, + // so it has no `summaries[METRIC].count` to gate on; the page itself + // data-gates each block and falls back to a calm empty note, so the + // pill is always present (like Overview) regardless of availability. + const noAvailability = render(); + expect(noAvailability).toContain(">Recovery<"); + expect(noAvailability).toContain('href="/insights/recovery"'); + + const emptyAvailability: InsightInputs = { + summaries: {}, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).toContain(">Recovery<"); + }); + it("lights up Mood when hasMood flips to true", () => { const availability: InsightInputs = { summaries: {}, @@ -389,6 +408,100 @@ describe(" — saved-layout visibility gate (v1.15.14 W2)", () }); }); +describe(" — module enable/disable gate (v1.18.0)", () => { + // A pill belonging to a toggleable module (mood / sleep / glucose / + // workouts) is hidden when that module is disabled in the account's + // resolved module map — on TOP of the data + layout gates. Core metric + // pills (BP, pulse, weight, BMI …) carry no module key and ignore it. + const moduleAvailability: InsightInputs = { + summaries: { + PULSE: fakeSummary(5), + WEIGHT: fakeSummary(3), + SLEEP_DURATION: fakeSummary(8), + BLOOD_GLUCOSE: fakeSummary(6), + }, + hasMood: true, + hasMedication: false, + hasWorkouts: true, + }; + + it("hides the Mood / Sleep / Workouts pills + the glucose (Metabolic) group when their modules are disabled", () => { + // `blood-glucose` is the sole metabolic metric with data here, so its + // module gate collapses the whole "Metabolic" group parent pill — + // mood / sleep / workouts are flat pills that drop directly. + const html = render( + , + ); + expect(html).not.toContain(">Mood<"); + expect(html).not.toContain(">Sleep<"); + expect(html).not.toContain(">Workouts<"); + expect(html).not.toContain('data-group="metabolic"'); + // Core metric pills are unaffected by the module map. + expect(html).toContain(">Pulse<"); + expect(html).toContain(">Weight<"); + expect(html).toContain(">BMI<"); + expect(html).toContain(">Overview<"); + }); + + it("shows the module pills + the glucose (Metabolic) group when their modules are enabled", () => { + const html = render( + , + ); + expect(html).toContain(">Mood<"); + expect(html).toContain(">Sleep<"); + expect(html).toContain(">Workouts<"); + expect(html).toContain('data-group="metabolic"'); + }); + + it("hides the Recovery pill when the recovery module is disabled, keeps it when enabled", () => { + const disabled = render( + , + ); + expect(disabled).not.toContain(">Recovery<"); + expect(disabled).not.toContain('href="/insights/recovery"'); + // Overview still anchors the strip. + expect(disabled).toContain(">Overview<"); + + const enabled = render(); + expect(enabled).toContain(">Recovery<"); + expect(enabled).toContain('href="/insights/recovery"'); + }); + + it("fails open: an empty / omitted module map keeps every module pill", () => { + // Default-on contract — a stale /me payload (no module map) must not + // blank the strip. An empty map and an omitted prop both keep pills. + for (const modules of [{}, undefined]) { + const html = render( + , + ); + expect(html).toContain(">Mood<"); + expect(html).toContain(">Sleep<"); + expect(html).toContain(">Workouts<"); + expect(html).toContain('data-group="metabolic"'); + expect(html).toContain(">Recovery<"); + } + }); +}); + describe(" — pill row clip box (v1.16.8)", () => { it("keeps vertical paint room inside the horizontal scroller", () => { // `overflow-x-auto` clips vertically too (overflow-y computes to diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index f2f545df5..95d0a3758 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -113,7 +113,7 @@ describe("", () => { }); it("hides itself on the Coach page", () => { - mockPathname = "/insights/coach"; + mockPathname = "/coach"; const html = render( diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index ae9cf5139..81869d47f 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -35,7 +35,7 @@ import { useCoachConversation, useSendCoachMessage } from "./use-coach"; /** * v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer * (``, right/bottom `` overlay) and the full-page - * Coach route (`/insights/coach`). + * Coach route (`/coach`). * * This component is the single source of truth for the Coach * conversation: it owns the active-conversation id, the streaming send diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 31e6e60f7..07885095e 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -38,7 +38,7 @@ export { * v1.12.0 (Coach v2 #6) — the chat surface itself (header actions, * body, thread, composer, rails, settings, mobile trays) is now the * shared `` component, reused verbatim by the - * full-page Coach route (`/insights/coach`). This file is the drawer + * full-page Coach route (`/coach`). This file is the drawer * CHROME only: the `` wrapper, the close button, and the * maximize control that hands the conversation off to the full page. */ @@ -85,7 +85,7 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) { // then route to the dedicated page. onOpenChange(false); resetRef.current?.(); - router.push("/insights/coach"); + router.push("/coach"); }, [onOpenChange, router]); return ( diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 228304661..ee2091d43 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -78,6 +78,15 @@ export interface HealthScoreCardProps { * starts collapsed. */ initiallyExpanded?: boolean; + /** + * v1.18.0 R4 — when the `mood` module is disabled for the account the + * server already drops the mood-stability pillar from the score (the + * pillar arrives null-weighted). This hides the Mood row from the + * component + provenance lists too so the card never advertises a + * pillar the user turned off. Default-on so omitting it keeps the + * four-row layout for the common (mood-enabled) case. + */ + moodEnabled?: boolean; } type ComponentKey = keyof HealthScoreCardProps["components"]; @@ -164,6 +173,7 @@ export function HealthScoreCard({ // eslint-disable-next-line @typescript-eslint/no-unused-vars onAskCoach: _onAskCoach, initiallyExpanded = false, + moodEnabled = true, }: HealthScoreCardProps) { const { t, locale } = useTranslations(); // The asOf timestamps render under the source pill as a tooltip @@ -198,9 +208,14 @@ export function HealthScoreCard({ // without relying on visual proximity alone. const deltaExplainerId = useId(); - const componentEntries = ( - Object.keys(components) as Array - ).map((key) => ({ + // v1.18.0 R4 — drop the Mood pillar from the card's row lists when the + // module is disabled. The server already null-weights it in the score; + // hiding the row keeps the card from naming a pillar the account turned + // off (it would otherwise render as a permanently empty "Mood" line). + const visibleKeys = (Object.keys(components) as Array).filter( + (key) => moodEnabled || key !== "mood", + ); + const componentEntries = visibleKeys.map((key) => ({ key, label: t(COMPONENT_LABEL_KEY[key]), value: components[key].value, @@ -211,6 +226,7 @@ export function HealthScoreCard({ // null values sink to the bottom (`weight * 0 === 0`); the tie-break // is the alphabetical key order so determinism holds across renders. const provenanceRows = COMPONENT_ORDER + .filter((key) => moodEnabled || key !== "mood") .map((key) => { const c = components[key]; const inferredSource: HealthScoreComponentSource = diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 88fd6221c..9b71bd770 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -7,6 +7,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useDisableCoach } from "@/hooks/use-disable-coach"; +import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { cn } from "@/lib/utils"; import { SuggestedPrompts } from "./suggested-prompts"; import { @@ -149,6 +150,11 @@ export function HeroStrip({ // tests render the strip without a QueryClient. const disableCoach = useDisableCoach(); const coachEnabled = flags.coach && !disableCoach; + // v1.18.0 R4 — mood-module state so the score card hides its Mood row + // when the account turned the module off (the server already drops the + // pillar from the number itself). SSR-safe + default-on, matching the + // disabled-allowlist gate. + const moodEnabled = useModuleEnabled("mood"); const greetingKey = resolveGreetingKey(now ?? new Date()); const greetingBase = t(greetingKey); const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase; @@ -339,6 +345,7 @@ export function HeroStrip({ band={healthScore.band} components={healthScore.components} delta={healthScore.delta} + moodEnabled={moodEnabled} onAskCoach={ // v1.4.37 W5 — short-circuit the prop drilling even // though `HealthScoreCard` retired its inline button in diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index fb37956ae..d6b7cacb8 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -1,7 +1,6 @@ "use client"; import { useMemo, type ReactNode } from "react"; -import { useSelectedLayoutSegment } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; @@ -54,7 +53,7 @@ interface ComprehensivePayload { export function InsightsLayoutShell({ children }: { children: ReactNode }) { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, user } = useAuth(); // v1.4.33 F18 — gate the advisor POST on the operator's assistant // feature flag. Pre-fix, every /insights mount fired POST // /api/insights/generate even when the operator had disabled the @@ -76,13 +75,6 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const advisorEnabled = mounted && isAuthenticated && flags.enabled && flags.briefing; - // v1.12.6 — the Coach segment is excluded from the generic disclaimer - // footer (v1.16.1 dropped the in-chat disclaimer line entirely; the - // chat surface stays footer-free by design). Every other segment — the - // overview (null segment), the metric sub-pages, mood, medications, the - // raw values table — keeps the single generic footer. - const segment = useSelectedLayoutSegment(); - const showDisclaimerFooter = segment !== "coach"; const advisor = useInsightsAdvisorQuery(advisorEnabled); // Shared analytics fetch — the layout shell only reads @@ -165,6 +157,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { availability={availability} visibleTileIds={visibleTileIds} tileOrder={tileOrder} + modules={user?.modules} /> {children} {/* v1.12.6 — SINGLE SOURCE of the generic Insights disclaimer. @@ -176,15 +169,15 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { rhythm events) stay on their own cards — those convey real, non-generic information. Other waves remove the generic line from the overview / mood / medications pages; this footer is where it lives. - Suppressed on the Coach route, which carries its own disclaimer. */} - {showDisclaimerFooter ? ( -

- {t("insights.disclaimer.footer")} -

- ) : null} + v1.18.0 — Coach moved to the standalone `/coach` route (its own + disclaimer), so this footer now renders under every `/insights/*` + route unconditionally. */} +

+ {t("insights.disclaimer.footer")} +

); } diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index f54d00086..3897468e7 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -34,6 +34,7 @@ import { type InsightInputs, type InsightMetric, } from "@/lib/insights/metric-availability"; +import type { ModuleKey } from "@/lib/modules/registry"; /** * v1.4.25 W4 — routed tab strip for `/insights`. @@ -114,6 +115,16 @@ export interface InsightsTabStripProps { * `SUB_PAGE_SLUGS` / `SUB_PAGE_GROUP_ORDER` order. */ tileOrder?: ReadonlyMap; + /** + * v1.18.0 — the account's resolved module enable/disable map (the + * `modules` field from `GET /api/auth/me`; cycle + coach already + * delegated). A pill belonging to a toggleable module (mood / sleep / + * glucose / workouts) is hidden when its key is `false`; the Recovery + * pill gates on `recovery`. Core metric pills carry no module key and are + * unaffected. Absent (legacy mounts / auth not yet loaded) keeps every + * pill — fail-open, matching the gate's default-on contract. + */ + modules?: InsightsModuleMap; } /** @@ -323,6 +334,46 @@ export const SUB_PAGE_TABS: Record< medications: { labelKey: "insights.navMedication", metric: "MEDICATION" }, }; +/** + * v1.18.0 — slug → module gate. A sub-page pill belonging to a toggleable + * module is hidden when that module is disabled in the account's resolved + * module map (from `GET /api/auth/me`'s `modules`), on TOP of the data + + * layout gates. Slugs absent from this map are core metrics (BP, pulse, + * weight, body composition, activity, cardio, hearing, environment, + * medications …) and never gate on a module — only on data availability. + * + * `labs` has no metric pill; it surfaces via the left nav only. The + * Recovery pill is gated separately below (it has no slug — it is a + * composite always-present link, so it reads the `recovery` module key + * directly in `buildTabs`). + */ +const SUB_PAGE_MODULE: Partial> = { + mood: "mood", + sleep: "sleep", + "breathing-disturbances": "sleep", + "blood-glucose": "glucose", + workouts: "workouts", +}; + +/** + * v1.18.0 — a partial `ModuleKey → enabled` map (the `modules` field + * `GET /api/auth/me` returns; cycle + coach already delegated). A pill + * gated by a module is hidden only when its key resolves to an explicit + * `false`; a missing key or an absent map (auth not yet loaded) keeps the + * pill — fail-open, mirroring the gate's default-on contract. + */ +export type InsightsModuleMap = Partial>; + +/** True unless the slug's module is explicitly disabled in the map. */ +function isSlugModuleEnabled( + slug: SubPageSlug, + modules: InsightsModuleMap | undefined, +): boolean { + const key = SUB_PAGE_MODULE[slug]; + if (!key) return true; + return modules?.[key] !== false; +} + /** * v1.4.34 IW-D — group metadata (label + popover header) keyed by * `SubPageGroup`. The strip renders one parent pill per group. v1.7.0 @@ -369,6 +420,7 @@ function buildTabs( availability: InsightInputs | undefined, visibleTileIds: ReadonlySet | undefined, tileOrder: ReadonlyMap | undefined, + modules: InsightsModuleMap | undefined, ): TabEntry[] { // v1.4.36 W4d — strict gating contract: a sub-page only surfaces a // pill when the availability inputs CONFIRM the metric has at @@ -391,11 +443,16 @@ function buildTabs( // behaviour — the layout query then narrows the set on resolve. The // group child `order` follows the layout (via `orderedSlugs` below), // falling back to `SUB_PAGE_GROUP_ORDER` when the layout is absent. + // v1.18.0 — a THIRD gate on top of data + layout: a pill belonging to a + // toggleable module (mood / sleep / glucose / workouts) is hidden when + // that module is disabled in the account's resolved module map. Core + // metric pills carry no module key and pass this gate unconditionally. const visibleSlugs = availability ? SUB_PAGE_SLUGS.filter( (slug) => hasMetricData(SUB_PAGE_TABS[slug].metric, availability) && - (visibleTileIds === undefined || visibleTileIds.has(slug)), + (visibleTileIds === undefined || visibleTileIds.has(slug)) && + isSlugModuleEnabled(slug, modules), ) : []; @@ -422,6 +479,20 @@ function buildTabs( }, ]; + // v1.18.0 — Recovery is an Insights pill, no longer a left-nav home. It + // is a composite wearable surface (WHOOP / Polar / Oura recovery, strain, + // ANS charge, cardio load), not a single MeasurementType, so it has no + // `summaries[METRIC].count` to gate on — the page data-gates every block + // and falls back to a calm empty note. The pill is otherwise present like + // Overview, but it is hidden when the `recovery` module is disabled. + if (modules?.recovery !== false) { + entries.push({ + kind: "link", + href: `${INSIGHTS_OVERVIEW_PATH}/recovery`, + labelKey: "insights.navRecovery", + }); + } + // v1.4.34 IW-D — track which groups have already been emitted so the // first encountered group-member triggers the parent pill at the // same strip position (preserving categorical order). @@ -472,6 +543,7 @@ function InsightsTabStripImpl({ availability, visibleTileIds, tileOrder, + modules, }: InsightsTabStripProps) { const { t } = useTranslations(); const pathname = usePathname(); @@ -488,8 +560,8 @@ function InsightsTabStripImpl({ // "Anpassen". The shell memoises both props so an unchanged layout // doesn't churn the array. const tabs = useMemo( - () => buildTabs(availability, visibleTileIds, tileOrder), - [availability, visibleTileIds, tileOrder], + () => buildTabs(availability, visibleTileIds, tileOrder, modules), + [availability, visibleTileIds, tileOrder, modules], ); // Fire a toast on the falling edge of `regenerating`. Same rising-edge ref diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index ef0f73f83..144045537 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -26,7 +26,7 @@ import { apiGet } from "@/lib/api/api-fetch"; * nudge (server-derived), or opened the Coach on this device (local * seen stamp keyed by the nudge timestamp). * - * The FAB hides inside the Coach view itself (`/insights/coach`) — a + * The FAB hides inside the Coach view itself (`/coach`) — a * launcher pointing at the page the user is already on is noise. * * It yields while a data-list selection bar is mounted (CSS `:has()` @@ -76,7 +76,7 @@ export function LayoutCoachFab() { }); const coachAvailable = !!launch && flags.coach && !disableCoach; - const onCoachPage = pathname?.startsWith("/insights/coach") ?? false; + const onCoachPage = pathname?.startsWith("/coach") ?? false; const { data: status } = useQuery({ queryKey: queryKeys.coachNudgeStatus(), diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index ab1a0cd5c..bf24b1201 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -2,9 +2,11 @@ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FlaskConical, Trash2 } from "lucide-react"; +import { FlaskConical } from "lucide-react"; import { toast } from "sonner"; +import { DeleteButton } from "@/components/data-list"; +import { formatReferenceRange } from "@/lib/labs/reference-range"; import { Button } from "@/components/ui/button"; import { Card, @@ -166,16 +168,14 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { r.value)} /> - + deleteMutation.mutate(group.latest.id)} + title={t("labs.deleteConfirmTitle")} + description={t("labs.deleteConfirmDescription")} + confirmLabel={t("labs.deleteLatest")} + className="size-9" + iconClassName="h-4 w-4" + /> @@ -187,12 +187,11 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { group.latest.referenceHigh !== null ? ( {t("labs.referenceLabel")}{" "} - {group.latest.referenceLow !== null && - group.latest.referenceHigh !== null - ? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}` - : group.latest.referenceHigh !== null - ? `≤ ${formatValue(group.latest.referenceHigh)}` - : `≥ ${formatValue(group.latest.referenceLow as number)}`} + {formatReferenceRange( + group.latest.referenceLow, + group.latest.referenceHigh, + formatValue, + )} ) : null} diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index 7e7a602bd..b1de1dbd8 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -7,8 +7,9 @@ * `aria-haspopup="dialog"`), not a navigation link * - the More button opens a hub (closed by default under SSR) * - the More hub is a real hub: Measurements + Mood (which left the - * always-visible strip) stay reachable here, alongside Workouts, - * Achievements, Notifications and Settings — nothing is orphaned + * always-visible strip) stay reachable here, alongside Achievements, + * Notifications and Settings — nothing is orphaned (v1.18.0 — Workouts + * and Recovery left the nav for their Insights pills) * - WCAG 2.5.5 floor: every strip entry is `min-h-11 min-w-11` * * v1.8.6 — the Targets (Zielwerte) page is deprecated and dropped from @@ -24,12 +25,13 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), })); -// `` reads `cycleTrackingEnabled` from `useAuth()` to gate the -// cycle hub entry. Each test mutates `mockUserRef.value` before rendering. +// v1.18.0 — `` reads the resolved per-user module map from +// `useAuth()` to gate the hub entries (cycle is the delegated `cycle` key). +// Each test mutates `mockUserRef.value` before rendering. const mockUserRef = { - value: { id: "u1", cycleTrackingEnabled: false } as { + value: { id: "u1", modules: {} } as { id: string; - cycleTrackingEnabled: boolean; + modules: Record; } | null, }; vi.mock("@/hooks/use-auth", () => ({ @@ -132,19 +134,49 @@ describe(" iOS-parity layout", () => { expect(hasLabelFirst || hasTestidFirst).toBe(true); }); - it("keeps the cycle hub entry out of the closed-hub strip when the gate is off", () => { - mockUserRef.value = { id: "u1", cycleTrackingEnabled: false }; + it("keeps the cycle hub entry out of the closed-hub strip when its module is off", () => { + mockUserRef.value = { id: "u1", modules: { cycle: false } }; const html = render(); // Like Measurements / Mood, the cycle entry lives in the More hub // (rendered on open — exercised in e2e), never an always-visible strip. expect(html).not.toContain('href="/cycle"'); }); - it("renders without error when the cycle gate is on (entry rides the More hub)", () => { - mockUserRef.value = { id: "u1", cycleTrackingEnabled: true }; + it("renders without error when the cycle module is on (entry rides the More hub)", () => { + mockUserRef.value = { id: "u1", modules: { cycle: true } }; // The closed-hub SSR markup carries no hub items either way; this guards - // that gating on the flag never throws. + // that gating on the module map never throws. expect(() => render()).not.toThrow(); - mockUserRef.value = { id: "u1", cycleTrackingEnabled: false }; + mockUserRef.value = { id: "u1", modules: {} }; + }); + + it("drops the Insights primary slot when the insights module is off (v1.18.0)", () => { + // The Insights slot is a fixed primary anchor, but it is now + // module-gated: a disabled insights module must remove it from the + // strip (and it is excluded from the More hub by the primary-slot + // filter, so the module is hidden everywhere, not relocated). + mockUserRef.value = { id: "u1", modules: { insights: false } }; + const html = render(); + expect(html).not.toContain('href="/insights"'); + // Sibling primary slots are unaffected. + expect(html).toContain('href="/"'); + expect(html).toContain('href="/medications"'); + mockUserRef.value = { id: "u1", modules: {} }; + }); + + it("keeps the Insights primary slot when the insights module is on", () => { + mockUserRef.value = { id: "u1", modules: { insights: true } }; + const html = render(); + expect(html).toContain('href="/insights"'); + mockUserRef.value = { id: "u1", modules: {} }; + }); + + it("gating on a disabled module never throws (v1.18.0)", () => { + mockUserRef.value = { + id: "u1", + modules: { mood: false, labs: false, coach: false, achievements: false }, + }; + expect(() => render()).not.toThrow(); + mockUserRef.value = { id: "u1", modules: {} }; }); }); diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index eefb35d2c..7b43cc980 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -3,9 +3,10 @@ * * Pins the coherence contract the desktop sidebar and the mobile * bottom-nav both consume: one ordered destination list (so the two bars - * tell one story), Workouts AND Coach as first-class destinations on both + * tell one story), the Coach as a first-class destination on both * surfaces, cycle gated by the account flag, and an active-resolver that - * prefers the most-specific sibling under `/insights`. + * prefers the most-specific sibling under `/insights`. (v1.18.0 — + * Workouts and Recovery left the left nav for their Insights pills.) */ import { describe, expect, it } from "vitest"; @@ -20,16 +21,13 @@ import { } from "../nav-model"; describe("nav-model destination list", () => { - it("lists Workouts and Coach as first-class destinations", () => { + it("lists Coach as a first-class destination", () => { const hrefs = NAV_DESTINATIONS.map((d) => d.href); - expect(hrefs).toContain("/insights/workouts"); - expect(hrefs).toContain("/insights/coach"); + expect(hrefs).toContain("/coach"); }); - it("gives the Coach exactly one nav home (F-3)", () => { - const coach = NAV_DESTINATIONS.filter( - (d) => d.href === "/insights/coach", - ); + it("gives the Coach exactly one nav home at the top-level /coach (F-3)", () => { + const coach = NAV_DESTINATIONS.filter((d) => d.href === "/coach"); expect(coach).toHaveLength(1); expect(coach[0]!.tKey).toBe("nav.coach"); }); @@ -56,11 +54,18 @@ describe("nav-model destination list", () => { expect(hrefs).toContain("/vorsorge"); const vorsorge = NAV_DESTINATIONS.find((d) => d.href === "/vorsorge"); expect(vorsorge?.tKey).toBe("nav.vorsorge"); - // Peer to Labs / Recovery: sits in the clinical spine, before Insights. + // Peer to Labs: sits in the clinical spine, before Insights. expect(hrefs.indexOf("/labs")).toBeLessThan(hrefs.indexOf("/vorsorge")); expect(hrefs.indexOf("/vorsorge")).toBeLessThan(hrefs.indexOf("/insights")); }); + it("does not list Recovery as a left-nav destination (it is an Insights pill)", () => { + // v1.18.0 — Recovery moved off the left nav and surfaces as an + // Insights tab-strip pill at `/insights/recovery` instead. + const hrefs = NAV_DESTINATIONS.map((d) => d.href); + expect(hrefs).not.toContain("/insights/recovery"); + }); + }); describe("nav-model utility tail (N-1 — one shared list)", () => { @@ -98,23 +103,79 @@ describe("nav-model utility tail (N-1 — one shared list)", () => { }); }); -describe("visibleNavDestinations cycle gate", () => { - it("includes Cycle (between Medications and Insights) only when enabled", () => { - const off = visibleNavDestinations(false).map((d) => d.href); - const on = visibleNavDestinations(true).map((d) => d.href); +describe("visibleNavDestinations module gate", () => { + it("includes Cycle (between Medications and Insights) only when its module is enabled", () => { + // v1.18.0 — cycle is `requiresModule: "cycle"` and reads the delegated + // `cycle` key from the same resolved module map every other gate uses. + const off = visibleNavDestinations({ cycle: false }).map((d) => d.href); + const on = visibleNavDestinations({ cycle: true }).map((d) => d.href); expect(off).not.toContain("/cycle"); expect(on).toContain("/cycle"); expect(on.indexOf("/medications")).toBeLessThan(on.indexOf("/cycle")); expect(on.indexOf("/cycle")).toBeLessThan(on.indexOf("/insights")); }); + + it("drops a module-gated entry (mood / labs / coach / achievements / insights) when its module is disabled", () => { + const disabled = visibleNavDestinations({ + mood: false, + labs: false, + coach: false, + achievements: false, + // v1.18.0 — Insights is now `requiresModule: "insights"`, so the + // top-level /insights entry drops when the module is off. + insights: false, + }).map((d) => d.href); + expect(disabled).not.toContain("/mood"); + expect(disabled).not.toContain("/labs"); + expect(disabled).not.toContain("/coach"); + expect(disabled).not.toContain("/achievements"); + expect(disabled).not.toContain("/insights"); + }); + + it("keeps every module-gated entry when its module is enabled", () => { + const enabled = visibleNavDestinations({ + mood: true, + labs: true, + coach: true, + achievements: true, + insights: true, + }).map((d) => d.href); + expect(enabled).toContain("/mood"); + expect(enabled).toContain("/labs"); + expect(enabled).toContain("/coach"); + expect(enabled).toContain("/achievements"); + expect(enabled).toContain("/insights"); + }); + + it("fails open: a missing key or an undefined map keeps the gated entry", () => { + // Default-on contract — a stale /me payload (no module map) must not + // blank the nav. An empty map and `undefined` both keep every entry. + const emptyMap = visibleNavDestinations({}).map((d) => d.href); + const noMap = visibleNavDestinations(undefined).map((d) => d.href); + for (const hrefs of [emptyMap, noMap]) { + expect(hrefs).toContain("/mood"); + expect(hrefs).toContain("/cycle"); + expect(hrefs).toContain("/labs"); + expect(hrefs).toContain("/coach"); + expect(hrefs).toContain("/achievements"); + } + // Core destinations always render regardless of the map. + expect(emptyMap).toContain("/measurements"); + expect(emptyMap).toContain("/medications"); + expect(emptyMap).toContain("/insights"); + expect(emptyMap).toContain("/vorsorge"); + }); }); describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => { - const opts = { cycleTrackingEnabled: false, bugReportEnabled: false }; + const opts = { + modules: { cycle: false } as const, + bugReportEnabled: false, + }; const hub = mobileMoreHubDestinations(opts).map((d) => d.href); - const expectedFeatures = visibleNavDestinations(opts.cycleTrackingEnabled) + const expectedFeatures = visibleNavDestinations(opts.modules) .map((d) => d.href) .filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href)); const expectedTail = visibleUtilityDestinations( @@ -126,7 +187,7 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { it("never contains a primary slot (Home / Meds / Insights)", () => { const hub = mobileMoreHubDestinations({ - cycleTrackingEnabled: true, + modules: { cycle: true }, bugReportEnabled: true, }).map((d) => d.href); for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) { @@ -136,7 +197,7 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { it("keeps the feature destinations and the utility tail reachable in the hub", () => { const hub = mobileMoreHubDestinations({ - cycleTrackingEnabled: false, + modules: { cycle: false }, bugReportEnabled: true, }).map((d) => d.href); // Feature surfaces that left the always-visible strip stay reachable. @@ -149,18 +210,20 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { expect(hub).toContain("/notifications"); }); - it("gates Cycle and Bug Report by the same flags the sidebar uses", () => { + it("gates Cycle by the module map and Bug Report by the operator flag, same as the sidebar", () => { const off = mobileMoreHubDestinations({ - cycleTrackingEnabled: false, + modules: { cycle: false, mood: false }, bugReportEnabled: false, }).map((d) => d.href); const on = mobileMoreHubDestinations({ - cycleTrackingEnabled: true, + modules: { cycle: true, mood: true }, bugReportEnabled: true, }).map((d) => d.href); expect(off).not.toContain("/cycle"); + expect(off).not.toContain("/mood"); expect(off).not.toContain("/bugreport"); expect(on).toContain("/cycle"); + expect(on).toContain("/mood"); expect(on).toContain("/bugreport"); }); }); @@ -171,19 +234,20 @@ describe("isNavDestinationActive most-specific resolution", () => { expect(isNavDestinationActive("/", "/measurements")).toBe(false); }); - it("does not light up Insights when on its Coach sibling", () => { - expect(isNavDestinationActive("/insights/coach", "/insights/coach")).toBe( - true, - ); - expect(isNavDestinationActive("/insights", "/insights/coach")).toBe(false); + it("lights up the top-level Coach home on its own route", () => { + expect(isNavDestinationActive("/coach", "/coach")).toBe(true); + // The standalone /coach route is not a sibling of /insights, so the + // Insights entry must stay dark on it. + expect(isNavDestinationActive("/insights", "/coach")).toBe(false); }); - it("does not light up Insights when on its Workouts sibling", () => { - expect( - isNavDestinationActive("/insights/workouts", "/insights/workouts"), - ).toBe(true); + it("lights up Insights on its Workouts sub-route (no longer a separate nav home)", () => { + // v1.18.0 — Workouts left the left nav for its Insights pill, so it is + // no longer a more-specific NAV_DESTINATIONS sibling. The Insights nav + // home now reads active on `/insights/workouts`, like any other + // `/insights/*` sub-route. expect(isNavDestinationActive("/insights", "/insights/workouts")).toBe( - false, + true, ); }); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index c60f50bc6..14e46af07 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -19,7 +19,10 @@ const mockUserRef = { email: "user@example.com", role: "USER" as "USER" | "ADMIN", avatarUrl: null, - cycleTrackingEnabled: false, + // v1.18.0 — nav gating reads the resolved per-user module map; cycle is + // the delegated `cycle` key on it (no bespoke boolean). An absent key + // fails open (entry shows), matching the gate's default-on contract. + modules: {} as Record, }, }; vi.mock("@/hooks/use-auth", () => ({ @@ -57,16 +60,16 @@ function render({ pathname = "/", bugReportEnabled = true, role = "USER" as "USER" | "ADMIN", - cycleTrackingEnabled = false, + modules = {} as Record, }: { pathname?: string; bugReportEnabled?: boolean; role?: "USER" | "ADMIN"; - cycleTrackingEnabled?: boolean; + modules?: Record; } = {}) { mockPathnameRef.value = pathname; mockSettingsRef.value = { bugReportEnabled }; - mockUserRef.value = { ...mockUserRef.value, role, cycleTrackingEnabled }; + mockUserRef.value = { ...mockUserRef.value, role, modules }; return renderToStaticMarkup( // The nav reads `useQueryClient()` for the medications intent // prefetch (v1.16.7); a fresh client per render keeps the SSR @@ -94,20 +97,20 @@ describe(" bug-report toggle", () => { }); }); -describe(" cycle entry gate (v1.15.0)", () => { - it("renders the Cycle entry when cycle tracking is enabled", () => { - const html = render({ cycleTrackingEnabled: true }); +describe(" cycle entry gate (v1.15.0 / v1.18.0 module map)", () => { + it("renders the Cycle entry when the cycle module is enabled", () => { + const html = render({ modules: { cycle: true } }); expect(html).toContain('href="/cycle"'); expect(html).toContain("Cycle"); }); - it("hides the Cycle entry when cycle tracking is disabled", () => { - const html = render({ cycleTrackingEnabled: false }); + it("hides the Cycle entry when the cycle module is disabled", () => { + const html = render({ modules: { cycle: false } }); expect(html).not.toContain('href="/cycle"'); }); it("places the Cycle entry between Medications and Insights when enabled", () => { - const html = render({ cycleTrackingEnabled: true }); + const html = render({ modules: { cycle: true } }); const med = html.indexOf('href="/medications"'); const cycle = html.indexOf('href="/cycle"'); const insights = html.indexOf('href="/insights"'); @@ -120,6 +123,52 @@ describe(" cycle entry gate (v1.15.0)", () => { }); }); +describe(" module gating (v1.18.0)", () => { + it("hides a disabled module's nav entry (mood / labs / coach / achievements)", () => { + const html = render({ + modules: { + mood: false, + labs: false, + coach: false, + achievements: false, + }, + }); + expect(html).not.toContain('href="/mood"'); + expect(html).not.toContain('href="/labs"'); + expect(html).not.toContain('href="/coach"'); + expect(html).not.toContain('href="/achievements"'); + // Core destinations stay regardless of the module map. + expect(html).toContain('href="/measurements"'); + expect(html).toContain('href="/medications"'); + expect(html).toContain('href="/insights"'); + expect(html).toContain('href="/vorsorge"'); + }); + + it("renders a module's nav entry when its module is enabled", () => { + const html = render({ + modules: { + mood: true, + labs: true, + coach: true, + achievements: true, + }, + }); + expect(html).toContain('href="/mood"'); + expect(html).toContain('href="/labs"'); + expect(html).toContain('href="/coach"'); + expect(html).toContain('href="/achievements"'); + }); + + it("fails open: an empty module map keeps every gated entry visible", () => { + const html = render({ modules: {} }); + expect(html).toContain('href="/mood"'); + expect(html).toContain('href="/cycle"'); + expect(html).toContain('href="/labs"'); + expect(html).toContain('href="/coach"'); + expect(html).toContain('href="/achievements"'); + }); +}); + describe(" targets deprecation (v1.8.6)", () => { it("no longer renders the deprecated /targets entry", () => { // The Targets (Zielwerte) page is deprecated; target editing moved @@ -133,22 +182,22 @@ describe(" targets deprecation (v1.8.6)", () => { }); describe(" unified destination model (v1.17.1 F-1 / F-3)", () => { - it("surfaces Workouts and the Coach as first-class sidebar destinations", () => { - // Pre-unify the sidebar hid Workouts entirely and had no Coach home, - // while the mobile bar promoted Workouts and still missed Coach. Both - // now render the one shared model, so both carry both destinations. + it("surfaces the Coach as a first-class sidebar destination", () => { + // Pre-unify the sidebar had no Coach home while the mobile bar missed + // it too; both now render the one shared model so both carry it. + // (v1.18.0 — Workouts left the left nav for its Insights pill, so it is + // no longer a sidebar destination.) const html = render(); - expect(html).toContain('href="/insights/workouts"'); - expect(html).toContain('href="/insights/coach"'); - expect(html).toContain("Workouts"); + expect(html).toContain('href="/coach"'); expect(html).toContain("Coach"); + expect(html).not.toContain('href="/insights/workouts"'); }); it("marks Coach active without also marking Insights active", () => { - const html = render({ pathname: "/insights/coach" }); - // The Coach link carries aria-current="page"; the Insights link, its - // less-specific sibling, must not (most-specific resolution). - const coach = html.match(/]*href="\/insights\/coach"[^>]*>/); + const html = render({ pathname: "/coach" }); + // The Coach link carries aria-current="page"; the standalone /coach + // route is not a sibling of /insights, so the Insights link must not. + const coach = html.match(/]*href="\/coach"[^>]*>/); const insights = html.match(/]*href="\/insights"[^>]*>/); expect(coach?.[0]).toMatch(/aria-current="page"/); expect(insights?.[0]).not.toMatch(/aria-current="page"/); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 02fb68705..d95f9a2bc 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -199,7 +199,7 @@ export function AuthShell({ // // v1.16.8 — `` joins the shell too: the floating // Coach launcher renders once here for every authenticated route - // (it hides itself on `/insights/coach` and carries the unread-nudge + // (it hides itself on `/coach` and carries the unread-nudge // dot). The routed insights layout no longer mounts it. return ( diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 45542a3ac..2ffd8c6a1 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -16,6 +16,7 @@ import { isNavDestinationActive, mobileMoreHubDestinations, } from "@/components/layout/nav-model"; +import type { ModuleKey } from "@/lib/modules/registry"; import { useMemo, useState } from "react"; import { useAppSettings } from "@/components/app-settings-provider"; import { useAuth } from "@/hooks/use-auth"; @@ -57,8 +58,21 @@ const PRIMARY_LEFT: ReadonlyArray = [ { href: "/medications", tKey: "nav.medications", icon: Pill }, ]; -const PRIMARY_RIGHT: ReadonlyArray = [ - { href: "/insights", tKey: "nav.insights", icon: Lightbulb }, +// v1.18.0 — the Insights primary slot is module-gated. Insights is now a +// toggleable module (`requiresModule: "insights"` in the shared nav model), +// so the fixed slot must respect the same per-user map the More hub already +// honours rather than pinning a destination the account turned off. When +// insights is disabled the slot is dropped (it is excluded from the More hub +// by `BOTTOM_NAV_PRIMARY_SLOT_HREFS`, so a disabled module is hidden +// everywhere, not relocated). Fail-open: a missing key / unloaded map keeps +// the slot, mirroring the gate's default-on contract. +const PRIMARY_RIGHT: ReadonlyArray = [ + { + href: "/insights", + tKey: "nav.insights", + icon: Lightbulb, + requiresModule: "insights", + }, ]; export function BottomNav() { @@ -80,10 +94,22 @@ export function BottomNav() { const moreHub = useMemo>( () => mobileMoreHubDestinations({ - cycleTrackingEnabled: user?.cycleTrackingEnabled, + modules: user?.modules, bugReportEnabled, }), - [user?.cycleTrackingEnabled, bugReportEnabled], + [user?.modules, bugReportEnabled], + ); + + // v1.18.0 — drop a module-gated primary slot (Insights) when the account + // has that module disabled. Fail-open: a missing key / unloaded map keeps + // the slot, mirroring the gate + More-hub default-on contract. + const primaryRight = useMemo( + () => + PRIMARY_RIGHT.filter( + (item) => + !item.requiresModule || user?.modules?.[item.requiresModule] !== false, + ), + [user?.modules], ); function isActiveLink(href: string) { @@ -160,7 +186,7 @@ export function BottomNav() {
- {PRIMARY_RIGHT.map(renderPrimary)} + {primaryRight.map(renderPrimary)} + ); + return (
-
-
-

- {t("measurementReminders.sectionTitle")} -

-

- {t("measurementReminders.sectionDescription")} -

+ {variant === "page" ? ( +
+
+

+ {t("measurementReminders.sectionTitle")} +

+

+ {t("measurementReminders.sectionDescription")} +

+
+ {addButton}
- -
+ ) : ( + + )} {showForm && ( @@ -365,16 +397,14 @@ function VorsorgeCard({ {t("measurementReminders.markDone")} - + diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3cf0bb210..693d1ef73 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -221,9 +221,12 @@ export function MedicationCard({ const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0; const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0; const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0; - // v1.15.9 — the open dose's server-derived status drives the card's green - // take-window highlight + the overdue / heavily-overdue top line. Defaults - // to "upcoming" (calm) when the display block is absent (older mocks). + // v1.15.9 — the open dose's server-derived status drives a discreet status + // line/glyph (text + icon only) and the overdue / heavily-overdue top line. + // Per the neutral med-card rule there is NO green take-window highlight and + // no grade-tinted card chrome — status is conveyed by a calm line, never a + // background/border tint. Defaults to "upcoming" (calm) when the display + // block is absent (older mocks). const doseStatus = display?.currentDose.status ?? "upcoming"; const categoryLabel = getMedicationCategoryLabel(medication.category, t); const sortedSchedules = [...medication.schedules].sort( diff --git a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts index b282a4e40..320fea350 100644 --- a/src/components/onboarding/__tests__/tour-launcher-gate.test.ts +++ b/src/components/onboarding/__tests__/tour-launcher-gate.test.ts @@ -21,6 +21,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { setTourForceLaunch, shouldAutoLaunchTour, + tourIncludesAchievements, TOUR_AUTOLAUNCH_DELAY_MS, tourForceLaunchKey, } from "../tour-launcher"; @@ -132,6 +133,27 @@ describe("shouldAutoLaunchTour() — v1.4.47 W5 chain-gate", () => { }); }); +describe("tourIncludesAchievements() — v1.18.0 B5 module gate", () => { + it("includes the achievements stop by default (no module map)", () => { + expect(tourIncludesAchievements(undefined)).toBe(true); + expect(tourIncludesAchievements({})).toBe(true); + }); + + it("includes the achievements stop when the module is explicitly on", () => { + expect(tourIncludesAchievements({ achievements: true })).toBe(true); + }); + + it("drops the achievements stop when the module is explicitly off", () => { + expect(tourIncludesAchievements({ achievements: false })).toBe(false); + }); + + it("ignores other disabled modules — only the achievements key matters", () => { + expect( + tourIncludesAchievements({ sleep: false, mood: false }), + ).toBe(true); + }); +}); + describe("setTourForceLaunch() — v1.4.47 W5 manual replay bypass", () => { // (c) of the W5 acceptance: the manual "launch tour" trigger still // works. The launcher's render-phase gate is exercised by diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index 73ade6e28..3095542df 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -101,6 +101,19 @@ export function shouldAutoLaunchTour(args: { return args.nowMs - completedMs >= TOUR_AUTOLAUNCH_DELAY_MS; } +/** + * v1.18.0 B5 — whether the achievements tour stop should be included. + * Reads the per-user module map with the standard client default-on + * convention (`modules?.achievements !== false`): only an explicit + * `false` drops the stop. Pure + exported so the rule is unit-pinned + * without rendering the launcher. + */ +export function tourIncludesAchievements( + modules: Partial> | undefined, +): boolean { + return modules?.achievements !== false; +} + // v1.4.15 H4 — sessionStorage keys are now scoped by user id so an // admin impersonating a second user does not inherit the first user's // "tour dismissed for this session" state. The previous global keys @@ -348,8 +361,15 @@ export function TourLauncher({ ready }: TourLauncherProps) { if (showTour !== true) return null; + // v1.18.0 B5 — drop the achievements tour stop when the account has the + // achievements module turned off. Reads the per-user module map the same + // way every other client gate does (`modules?. !== false`, + // default-on). + const includeAchievements = tourIncludesAchievements(user?.modules); + return ( { // Optimistic: hide the overlay immediately, persist in the // background. The flag is idempotent so re-fires are safe; diff --git a/src/components/settings/__tests__/ai-section.test.tsx b/src/components/settings/__tests__/ai-section.test.tsx index 3df5f94a1..23b061286 100644 --- a/src/components/settings/__tests__/ai-section.test.tsx +++ b/src/components/settings/__tests__/ai-section.test.tsx @@ -196,27 +196,12 @@ describe(" — dropdown-driven UX (B2)", () => { expect(html).toContain("Primärer Provider"); }); - // v1.4.47 W3 — per-user "Hide Coach" toggle. The card lives at the - // bottom of the AI section so privacy-leaning users never scroll - // past the provider configuration to find it. - it("mounts the Hide-Coach toggle card", () => { + // v1.18.0 (S5) — the Coach preference cards (disable toggle, preferences, + // memory) moved out to the dedicated Coach section; the AI section keeps + // only provider / model / BYOK plus the "about me" context. + it("no longer mounts the Coach cards (moved to the Coach section)", () => { activeProviderQuery = "codex"; const html = render(); - expect(html).toContain('data-testid="settings-disable-coach-card"'); - expect(html).toContain('data-testid="settings-disable-coach-switch"'); - // The localised label + helper line are both surfaced — proves - // the i18n keys resolve and the description string ships with the - // bilingual copy required by the QoL audit (M2). - expect(html).toContain("Hide Coach"); - expect(html).toContain("Hides the Coach button and drawer everywhere."); - }); - - it("renders the German Hide-Coach copy", () => { - activeProviderQuery = "codex"; - const html = render("de"); - expect(html).toContain("Coach ausblenden"); - expect(html).toContain( - "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.", - ); + expect(html).not.toContain('data-testid="settings-disable-coach-card"'); }); }); diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx new file mode 100644 index 000000000..09c8be1a4 --- /dev/null +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -0,0 +1,66 @@ +/** + * v1.18.0 (S5) — Settings → Coach section. + * + * The Coach preference cards (disable toggle, preferences, memory) moved + * out of the AI / Assistant section into their own dedicated, module-gated + * entry. SSR-only smoke test — interactive contracts live in the cards' + * own suites (`disable-coach-card.test.tsx`, `coach-memory-section.test.tsx`). + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/coach", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +const authState: { disableCoach: boolean } = { disableCoach: false }; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER", disableCoach: authState.disableCoach }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { CoachSection } from "../coach-section"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — SSR smoke", () => { + it("renders the section scaffold + the Hide-Coach toggle card", () => { + authState.disableCoach = false; + const html = render(); + expect(html).toContain('id="settings-section-coach-title"'); + expect(html).toContain('data-testid="settings-disable-coach-card"'); + expect(html).toContain('data-testid="settings-disable-coach-switch"'); + expect(html).toContain("Hide Coach"); + expect(html).toContain("Hides the Coach button and drawer everywhere."); + // Raw key never leaks past i18n. + expect(html).not.toContain("settings.sections.coach."); + }); + + it("renders the German Hide-Coach copy", () => { + authState.disableCoach = false; + const html = render("de"); + expect(html).toContain("Coach ausblenden"); + expect(html).toContain( + "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.", + ); + expect(html).not.toContain("settings.sections.coach."); + }); +}); diff --git a/src/components/settings/__tests__/dashboard-layout-section.test.tsx b/src/components/settings/__tests__/dashboard-layout-section.test.tsx index 386955c60..5811bc4ae 100644 --- a/src/components/settings/__tests__/dashboard-layout-section.test.tsx +++ b/src/components/settings/__tests__/dashboard-layout-section.test.tsx @@ -57,6 +57,17 @@ vi.mock("sonner", () => ({ }, })); +// v1.18.0 — mutable module map so the widget-toggle gating tests can flip a +// module off without re-mocking. Defaults to `undefined` (no map → fail-open, +// every web-renderable row shown), preserving the pre-existing row counts. +const authState: { + modules: Partial> | undefined; +} = { modules: undefined }; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: { modules: authState.modules } }), +})); + import { I18nProvider } from "@/lib/i18n/context"; import { DashboardLayoutSection, @@ -71,6 +82,7 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") { beforeEach(() => { queryState.layout = DEFAULT_DASHBOARD_LAYOUT; + authState.modules = undefined; }); describe(" — tile + chart split", () => { @@ -382,3 +394,47 @@ describe(" — iOS-pin-only ids hidden from web (v1.11.2 } }); }); + +/** + * v1.18.0 — a dashboard widget toggle whose owning module is disabled is a + * dead control: the snapshot gates the tile/chart out server-side no matter + * what the switch says. The Settings list must hide those rows. Core widgets + * (no module entry in `WIDGET_MODULE_BY_ID`) always show; the gate fails open + * when the module map is absent or the key is unset. + */ +describe(" — disabled-module widget toggles", () => { + // The achievements widget label drives its switch aria-label; it is the + // canonical disabled-module probe (label resolves to "Achievements"). + const ACHIEVEMENTS_ARIA = "Achievements — "; + // Mood is a module-owned widget too; weight is a core widget with NO module + // entry, so it must survive any module-off state. + const MOOD_ARIA = "Mood — "; + const WEIGHT_ARIA = "Weight — "; + + it("hides a widget toggle whose owning module is disabled", () => { + authState.modules = { achievements: false }; + const html = render(); + // The achievements row (and its switches) are gone… + expect(html).not.toContain(ACHIEVEMENTS_ARIA); + // …and exactly one fewer web-renderable row renders. + const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? []; + expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT - 1); + }); + + it("keeps an enabled-module widget and core (no-module) widgets shown", () => { + authState.modules = { achievements: false }; + const html = render(); + // Enabled module (mood) still renders… + expect(html).toContain(MOOD_ARIA); + // …and a core widget with no module entry (weight) always shows. + expect(html).toContain(WEIGHT_ARIA); + }); + + it("shows every row when the module map is absent (fail-open)", () => { + authState.modules = undefined; + const html = render(); + const tileSwitches = html.match(/data-slot="widget-tile-switch"/g) ?? []; + expect(tileSwitches).toHaveLength(WEB_RENDERABLE_ROW_COUNT); + expect(html).toContain(ACHIEVEMENTS_ARIA); + }); +}); diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index cb4468abf..b93cbea27 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -32,12 +32,15 @@ vi.mock("@/hooks/use-auth", async () => { }; }); -// Stub every other query hook the parent `` triggers so -// the SSR pass doesn't blow up. The disable-coach card itself doesn't -// trigger any GET — it only writes through PATCH on click. +// Stub the navigation hooks the parent `` (and its child +// cards) reach for so the SSR pass doesn't blow up. The disable-coach card +// itself doesn't trigger any GET — it only writes through PATCH on click. +// +// v1.18.0 (S5) — the Coach cards moved from `` to the dedicated +// ``; this contract test renders the new parent. vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/settings/ai", + usePathname: () => "/settings/coach", useSearchParams: () => new URLSearchParams(""), })); @@ -63,10 +66,11 @@ function buildUser(disableCoach: boolean): AuthUser { insurerIkNumber: null, insuranceNumber: null, cycleTrackingEnabled: false, + modules: {}, }; } -import { AiSection } from "../ai-section"; +import { CoachSection } from "../coach-section"; function makeFetch() { return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { @@ -103,7 +107,7 @@ function render(): string { return renderToStaticMarkup( - + , ); diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx index fffd2e24f..264277863 100644 --- a/src/components/settings/__tests__/export-section.test.tsx +++ b/src/components/settings/__tests__/export-section.test.tsx @@ -2,7 +2,6 @@ * v1.4.16 Phase B7 — Settings → Export section. * * The consolidated Export page surfaces: - * - Health Record export panel (the page hero, v1.12) * - Measurements CSV * - Medications CSV (with optional intake-history toggle) * - Mood CSV @@ -12,10 +11,10 @@ * download/generate button. SSR-only smoke test — interaction is * exercised by the e2e suite. * - * The health-record export takes the page hero. The doctor-report PDF - * now lives under the health-record export and is no longer offered as - * a separate card here. The four CSV/JSON tiles stay under a "Weitere - * Export-Optionen" / "Other export options" sub-heading. Each component + * v1.18.0 (S5) — the full health-record export moved out to its own + * top-level "Gesundheitsakte" section; this page keeps only the generic + * CSV/JSON data-out paths under a "Weitere Export-Optionen" / "Other + * export options" sub-heading plus the import surface. Each component * owns its own contract test; this suite pins the page-level shape. */ import { describe, expect, it, vi } from "vitest"; @@ -77,12 +76,11 @@ describe(" — SSR smoke", () => { expect(html).not.toContain('data-testid="export-card-cycle"'); }); - it("mounts the health-record export panel as the page hero", () => { + it("no longer mounts the health-record export panel (moved to Gesundheitsakte)", () => { const html = render(); - // v1.12 — the health-record export owns the hero treatment. - expect(html).toContain('data-testid="health-record-export-panel"'); - expect(html).toContain("hero-gradient"); - expect(html).toContain("glow-purple"); + // v1.18.0 (S5) — the full health-record export lives in its own + // top-level Gesundheitsakte section now, not on this page. + expect(html).not.toContain('data-testid="health-record-export-panel"'); }); it("no longer mounts a separate doctor-report card", () => { @@ -130,7 +128,7 @@ describe(" — SSR smoke", () => { expect(buttonMatches?.length ?? 0).toBe(4); }); - it("German locale renders the DE heading + hero copy", () => { + it("German locale renders the DE heading + sub-heading copy", () => { const html = render(, "de"); expect(html).toContain("Export"); expect(html).toContain("Weitere Export-Optionen"); diff --git a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx new file mode 100644 index 000000000..57ab31493 --- /dev/null +++ b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx @@ -0,0 +1,54 @@ +/** + * v1.18.0 (S5) — Settings → Gesundheitsakte section. + * + * The full health-record export (PDF + FHIR R4 + zip package) lifted out + * of Export & Import into its own top-level home. SSR-only smoke test — + * interaction is exercised by the panel's own contract test + e2e. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/gesundheitsakte", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER" }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { GesundheitsakteSection } from "../gesundheitsakte-section"; + +function render(node: React.ReactElement, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" — SSR smoke", () => { + it("renders the section description and the health-record panel", () => { + const html = render(); + expect(html).toContain('id="settings-section-gesundheitsakte-title"'); + expect(html).toContain('data-testid="health-record-export-panel"'); + // Raw key never leaks past i18n. + expect(html).not.toContain("settings.sections.gesundheitsakte."); + }); + + it("German locale resolves without leaking the raw key", () => { + const html = render(, "de"); + expect(html).toContain('data-testid="health-record-export-panel"'); + expect(html).not.toContain("settings.sections.gesundheitsakte."); + }); +}); diff --git a/src/components/settings/__tests__/layout-section.test.tsx b/src/components/settings/__tests__/layout-section.test.tsx index 422fe8fcd..12e9be175 100644 --- a/src/components/settings/__tests__/layout-section.test.tsx +++ b/src/components/settings/__tests__/layout-section.test.tsx @@ -20,29 +20,26 @@ function render(locale: "en" | "de" = "en") { } describe("", () => { - it("links to all four personalization editors", () => { + it("links to the arrangement editors it still hosts", () => { const html = render(); - for (const href of [ - "/settings/dashboard", - "/settings/insights", - "/settings/medications", - "/settings/mood", - ]) { + for (const href of ["/settings/dashboard", "/settings/insights"]) { expect(html).toContain(`href="${href}"`); } + // v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung) + // graduated to their own nav entries and are no longer linked here. + expect(html).not.toContain('href="/settings/medications"'); + expect(html).not.toContain('href="/settings/mood"'); }); it("renders the hub heading and a card per editor", () => { const html = render(); expect(html).toContain("Dashboard"); expect(html).toContain("Insights"); - expect(html).toContain("Medications"); - expect(html).toContain("Mood tags"); }); it("resolves its copy in German too", () => { const html = render("de"); - expect(html).toContain("Medikamente"); - expect(html).toContain("Stimmungs-Tags"); + expect(html).toContain("Layout"); + expect(html).not.toContain("settings.sections.layout."); }); }); diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index 901ed17ef..3ff5eb734 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -36,7 +36,13 @@ vi.mock("@tanstack/react-query", () => ({ } return { data: null, isLoading: false }; }, - useQueryClient: () => ({ setQueryData: vi.fn(), getQueryData: vi.fn() }), + useQueryClient: () => ({ + setQueryData: vi.fn(), + getQueryData: vi.fn(), + invalidateQueries: vi.fn(), + }), + // v1.18.0 (S5) — the gathered injection-sites card uses `useMutation`. + useMutation: () => ({ mutate: vi.fn(), isPending: false }), })); vi.mock("sonner", () => ({ @@ -47,6 +53,17 @@ vi.mock("@/lib/charts/reduced-motion", () => ({ prefersReducedMotion: () => true, })); +// v1.18.0 (S5) — the section now reads `useAuth()` to gate the +// injection-sites card it gathered from the account profile. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER" }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + import { I18nProvider } from "@/lib/i18n/context"; import { MedicationsSection } from "../medications-section"; @@ -66,7 +83,9 @@ describe(" — SSR smoke", () => { it("renders the section heading + description via i18n (no raw keys)", () => { const html = render(); expect(html).toContain("settings-section-medications-title"); - expect(html).toContain("List view and medication order."); + expect(html).toContain( + "List view, order, and injection sites for your medications.", + ); expect(html).not.toContain("settings.sections."); expect(html).not.toContain("medications.view"); expect(html).not.toContain("medications.reorder"); @@ -74,7 +93,9 @@ describe(" — SSR smoke", () => { it("renders the German copy end-to-end", () => { const html = render("de"); - expect(html).toContain("Listenansicht und Reihenfolge der Medikamente."); + expect(html).toContain( + "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente.", + ); expect(html).toContain("Reihenfolge anpassen"); expect(html).toContain( "Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.", @@ -111,6 +132,13 @@ describe(" — SSR smoke", () => { expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin")); }); + it("gathers the injection-sites card (moved from the account profile)", () => { + // v1.18.0 (S5) — injection-site exclusions are a medication setting and + // now live on this screen alongside the list view + order. + const html = render(); + expect(html).toContain("Globally excluded injection sites"); + }); + it("feeds the editor the saved order (active block re-sorted by layout.order)", () => { // Alphabetical default would put Aspirin first; the saved order pins // Ramipril to the top — the editor must open showing exactly what diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx new file mode 100644 index 000000000..af1183e3b --- /dev/null +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -0,0 +1,201 @@ +/** + * v1.18.0 — Settings → Module ("Was du trackst") hub contract. + * + * `` lists every toggleable module with a `` bound + * to the resolved module map (`useAuth().user.modules`), plus a read-only + * "always on" group for the four core domains. Flipping a toggle PATCHes + * `/api/auth/me/modules` (a DISABLED allowlist) and invalidates the + * `authMe()` query so nav / pills / tiles re-gate live. + * + * Test strategy mirrors `advanced-research-mode.test.tsx`: mock + * `@tanstack/react-query` so the rendered tree executes under SSR + * (`renderToStaticMarkup`), capture the `useMutation` config to assert the + * PATCH body + the `authMe()` invalidation directly, and mock `useAuth` to + * drive the disabled-module case. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AuthUser } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { + MODULE_KEYS, + CORE_DOMAIN_KEYS, + moduleDelegatesTo, +} from "@/lib/modules/registry"; + +// Capture the latest useMutation config + a shared mutate spy so we can +// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a +// real DOM click). +type MutationConfig = { + mutationFn?: (vars: unknown) => Promise; + onSuccess?: (data?: unknown) => void; + onError?: (err?: unknown) => void; +}; +let lastMutation: MutationConfig | null = null; +const mutateSpy = vi.fn(); +const invalidateSpy = vi.fn(); + +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ invalidateQueries: invalidateSpy }), + useMutation: (config: MutationConfig) => { + lastMutation = config; + return { mutate: mutateSpy, isPending: false }; + }, +})); + +const authSpy = vi.fn<() => { user: AuthUser | null; isAuthenticated: boolean }>( + () => ({ user: buildUser({}), isAuthenticated: true }), +); +vi.mock("@/hooks/use-auth", async () => { + const actual = + await vi.importActual( + "@/hooks/use-auth", + ); + return { ...actual, useAuth: () => authSpy() }; +}); + +function buildUser(modules: AuthUser["modules"]): AuthUser { + return { + id: "user-1", + username: "testuser", + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + onboardingCompletedAt: null, + onboardingTourCompleted: true, + avatarUrl: null, + glucoseUnit: null, + unitPreference: "metric", + timeFormat: "AUTO", + disableCoach: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumber: null, + cycleTrackingEnabled: false, + modules, + }; +} + +import { ModulesSection } from "../modules-section"; + +function render(locale: "en" | "de" = "en"): string { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + lastMutation = null; + mutateSpy.mockClear(); + invalidateSpy.mockClear(); + authSpy.mockImplementation(() => ({ + user: buildUser({}), + isAuthenticated: true, + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("", () => { + it("renders a Switch for every non-delegated toggleable module, a managed deep-link for delegated ones, and locked switches for core", () => { + const html = render(); + for (const key of MODULE_KEYS) { + if (moduleDelegatesTo(key) !== undefined) { + // Delegated modules (cycle/coach) are owned by their real control + // elsewhere — they render as a read-only "manage in X" deep-link, + // never a live Switch that would write an inert disabled-allowlist + // entry the gate ignores. + expect(html, `delegated has no switch ${key}`).not.toContain( + `id="module-toggle-${key}"`, + ); + } else { + expect(html, `toggleable switch ${key}`).toContain( + `id="module-toggle-${key}"`, + ); + } + } + for (const key of CORE_DOMAIN_KEYS) { + expect(html, `core switch ${key}`).toContain(`id="module-toggle-${key}"`); + } + }); + + it("seeds an enabled module checked (default-on) and a disabled one unchecked", () => { + authSpy.mockImplementation(() => ({ + user: buildUser({ sleep: false }), + isAuthenticated: true, + })); + const html = render(); + + // The disabled `sleep` toggle surfaces data-state="unchecked". + const sleepTag = html.match( + /]*id="module-toggle-sleep"[^>]*>/, + )?.[0]; + expect(sleepTag).toBeDefined(); + expect(sleepTag).toMatch(/data-state="unchecked"/); + + // An absent key (`mood`) reads as enabled → checked. + const moodTag = html.match( + /]*id="module-toggle-mood"[^>]*>/, + )?.[0]; + expect(moodTag).toMatch(/data-state="checked"/); + }); + + it("renders the core domains locked (checked + disabled)", () => { + const html = render(); + for (const key of CORE_DOMAIN_KEYS) { + const tag = html.match( + new RegExp(`]*id="module-toggle-${key}"[^>]*>`), + )?.[0]; + expect(tag, `core ${key}`).toBeDefined(); + expect(tag).toMatch(/data-state="checked"/); + expect(tag).toMatch(/disabled/); + } + }); + + it("PATCHes only the flipped key as a disabled-allowlist entry", async () => { + render(); + expect(lastMutation?.mutationFn).toBeTypeOf("function"); + + const fetchSpy = vi.fn( + async () => + new Response(JSON.stringify({ data: { modules: {} } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + + await lastMutation!.mutationFn!({ key: "glucose", enabled: false }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toContain("/api/auth/me/modules"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ glucose: false }); + }); + + it("invalidates the authMe query on a successful toggle", () => { + render(); + expect(lastMutation?.onSuccess).toBeTypeOf("function"); + + lastMutation!.onSuccess!(); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: queryKeys.authMe(), + }); + }); +}); diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx index edfb4d47a..f933bc633 100644 --- a/src/components/settings/__tests__/mood-section.test.tsx +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -150,14 +150,14 @@ describe(" — SSR smoke", () => { it("renders the section heading + description via i18n (no raw keys)", () => { const html = render(); expect(html).toContain("settings-section-mood-title"); - expect(html).toContain("Groups, tags, and order of the mood picker."); + expect(html).toContain("Groups, tags, and archived tags for the mood picker."); expect(html).not.toContain("settings.sections."); expect(html).not.toContain("mood.manage."); }); it("renders the German copy end-to-end", () => { const html = render("de"); - expect(html).toContain("Gruppen, Tags und Reihenfolge der Tag-Auswahl."); + expect(html).toContain("Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl."); expect(html).toContain("Neue Gruppe"); expect(html).toContain("Neuer Tag"); expect(html).toContain("Archivierte Tags"); diff --git a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx new file mode 100644 index 000000000..185d6b65e --- /dev/null +++ b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx @@ -0,0 +1,84 @@ +/** + * v1.18.0 (S4) — module-gated per-type visibility on the consolidated + * "Benachrichtigungen" reminder-types home. + * + * The mood reminder shows only when the `mood` module is enabled; the Coach + * nudge only when `coach` is enabled. Low-stock maps to the medications CORE + * domain (always shown). The gate fails OPEN, so a `/me` payload without a + * module map keeps every card visible. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/notifications", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +// The auth mock is driven per-test via a mutable ref so each render can pick +// a different module map without re-mocking the module. +const authRef: { modules?: Record } = {}; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", username: "t", role: "USER", modules: authRef.modules }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { NotificationsSection } from "../notifications-section"; + +function render(modules?: Record) { + authRef.modules = modules; + return renderToStaticMarkup( + + + , + ); +} + +describe(" module-gated reminder types", () => { + it("shows mood + coach cards when no module map (fails open)", () => { + const html = render(undefined); + expect(html).toContain('id="mood-reminder"'); + expect(html).toContain('id="coach-nudge"'); + expect(html).toContain('id="low-stock"'); + }); + + it("hides the mood card when the mood module is disabled", () => { + const html = render({ mood: false }); + expect(html).not.toContain('id="mood-reminder"'); + // Coach + low-stock are unaffected. + expect(html).toContain('id="coach-nudge"'); + expect(html).toContain('id="low-stock"'); + }); + + it("hides the coach card when the coach module is disabled", () => { + const html = render({ coach: false }); + expect(html).not.toContain('id="coach-nudge"'); + expect(html).toContain('id="mood-reminder"'); + expect(html).toContain('id="low-stock"'); + }); + + it("always shows low-stock (medications is a CORE domain)", () => { + const html = render({ mood: false, coach: false }); + expect(html).toContain('id="low-stock"'); + expect(html).not.toContain('id="mood-reminder"'); + expect(html).not.toContain('id="coach-nudge"'); + }); + + it("shows the channels pointer (delivery channels live under Integrations)", () => { + const html = render({}); + expect(html).toContain('data-slot="notifications-channels-cross-link"'); + expect(html).toContain('href="/settings/integrations"'); + }); +}); diff --git a/src/components/settings/__tests__/reminders-section.test.tsx b/src/components/settings/__tests__/reminders-section.test.tsx deleted file mode 100644 index 3305f95d1..000000000 --- a/src/components/settings/__tests__/reminders-section.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/** - * v1.17.1 — the "Reminders & Notifications" hub. - * - * The hub is the single front door for every reminder concept. It groups the - * reminder CATEGORIES (medication / Vorsorge / mood / low-stock / coach) and - * links to the notification CHANNELS, with each link deep-linking into the - * canonical editor so nothing is rewritten — only gathered in one place. - */ -import { describe, expect, it } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { RemindersSection } from "../reminders-section"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("", () => { - it("deep-links to every canonical reminder editor", () => { - const html = render(); - for (const href of [ - "/medications", - "/vorsorge", - "/settings/notifications#mood-reminder", - "/settings/notifications#low-stock", - "/settings/notifications#coach-nudge", - ]) { - expect(html).toContain(`href="${href}"`); - } - }); - - it("links to the notification channels screen (the how/where)", () => { - const html = render(); - expect(html).toContain('href="/settings/notifications"'); - expect(html).toContain('data-testid="reminders-link-channels"'); - }); - - it("renders a card for each reminder category", () => { - const html = render(); - for (const testId of [ - "reminders-link-medication", - "reminders-link-vorsorge", - "reminders-link-mood", - "reminders-link-low-stock", - "reminders-link-coach", - ]) { - expect(html).toContain(`data-testid="${testId}"`); - } - }); - - it("separates categories (what/when) from channels (how/where)", () => { - const html = render(); - expect(html).toContain("Reminders"); - expect(html).toContain("Delivery channels"); - }); - - it("resolves its copy in German too", () => { - const html = render("de"); - expect(html).toContain("Medikamenten-Erinnerungen"); - expect(html).toContain("Zustellkanäle"); - }); -}); diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index d1d436bff..fab34abc4 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -182,12 +182,24 @@ describe("settings sections — SSR smoke", () => { expect(html).toContain("Withings"); }); - it(" renders heading", () => { + it(" renders the single reminder-types home", () => { const html = render(); - // v1.4.33 IW7 — section renamed from "Notifications" to - // "Notification channels" so it doesn't collide with the inbox at - // `/notifications` ("Notifications"). - expect(html).toContain("Notification channels"); + // v1.18.0 (S4) — one clean heading (sr-only h1 + visible description), + // no stacked breadcrumb. The screen is the single module-gated home for + // reminder TYPES. + expect(html).toContain("settings-section-notifications-title"); + // The old stacked breadcrumb middle crumb is gone. + expect(html).not.toContain("Notification channels"); + // A single concise pointer to the channels (now under Integrations). + expect(html).toContain('href="/settings/integrations"'); + expect(html).toContain('data-slot="notifications-channels-cross-link"'); + // The mood + coach cards fail OPEN (mock user carries no module map), + // so both reminder-type cards render. + expect(html).toContain('id="mood-reminder"'); + expect(html).toContain('id="coach-nudge"'); + expect(html).toContain('id="low-stock"'); + // No raw i18n key leaks past the provider. + expect(html).not.toContain("settings.sections.notifications."); }); it(" renders heading and embeds the layout customizer", () => { diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index b10b03c07..a7fbe3a8f 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -11,6 +11,22 @@ vi.mock("next/navigation", () => ({ usePathname: () => mockPathnameRef.value, })); +// v1.18.0 (S5) — the shell module-gates per-submodule nav entries off +// `useAuth().user.modules`. The mock returns a user whose modules map is +// mutated per-test via `mockModulesRef`. `undefined` (the default) means +// every gate fails open, so all entries render. +const mockModulesRef: { value: Record | undefined } = { + value: undefined, +}; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER", modules: mockModulesRef.value }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + import { I18nProvider } from "@/lib/i18n/context"; import { SETTINGS_SECTION_SLUGS, @@ -23,8 +39,10 @@ function renderShell(props: { active?: (typeof SETTINGS_SECTION_SLUGS)[number]; pathname?: string; locale?: "en" | "de"; + modules?: Record; }) { mockPathnameRef.value = props.pathname ?? "/settings/account"; + mockModulesRef.value = props.modules; return renderToStaticMarkup( @@ -45,9 +63,10 @@ describe("SETTINGS_SECTION_SLUGS", () => { // `api` and `advanced` so every "give me my data out" path lives in // one place. // v1.4.25 W5e added `sources` between `thresholds` and `ai`; v1.4.34 - // IW-D merged it into `thresholds`. - // v1.8.7.1 — `sources` (Sources) is its own slug again, sitting - // between `thresholds` (Targets) and `ai`. + // IW-D merged it into `thresholds`; v1.8.7.1 split it back out. + // v1.18.0 (S3) — `sources` is no longer a standalone slug: source + // priority folded into Settings → Integrations as the "Sources" + // sub-tab. `/settings/sources` 301-redirects to `/settings/integrations`. // v1.15.18 — `insights` sits between `dashboard` and `thresholds`: the // overview-arrange + pill-sort customise surface for `/insights`. // v1.16.10 — `medications` sits between `insights` and `thresholds`: @@ -61,22 +80,30 @@ describe("SETTINGS_SECTION_SLUGS", () => { // slugs (`dashboard`, `insights`, `medications`, `mood`) keep their // routes so deep links resolve, but they are reached through the // Layout hub instead of four standalone nav entries. - // v1.17.1 — `reminders` is the one "Reminders & Notifications" home and - // sits right after `notifications`. + // v1.18.0 (S4) — the standalone `reminders` hub is gone; reminder TYPES + // live in `notifications`, each gated on its module. `/settings/reminders` + // 301-redirects to `/settings/notifications`. + // v1.18.0 — `modules` ("Was du trackst") sits right after `account` as + // the single front door for enabling/disabling secondary domains. + // v1.18.0 (S5) — `gesundheitsakte` (the full health-record export) lifts + // out of Export & Import into its own top-level entry, before `export`; + // `coach` gathers the Coach preference cards out of the AI section and + // sits right after `ai`. expect([...SETTINGS_SECTION_SLUGS]).toEqual([ "account", + "modules", "integrations", "notifications", - "reminders", "layout", "dashboard", "insights", "medications", "mood", "thresholds", - "sources", "ai", + "coach", "api", + "gesundheitsakte", "export", "advanced", "about", @@ -153,12 +180,7 @@ describe("", () => { // On `/settings/dashboard` (a Layout child reached through the hub) the // Layout nav entry must read active even though Dashboard has no nav // entry of its own. Both layouts emit the active link → two matches. - for (const child of [ - "/settings/dashboard", - "/settings/insights", - "/settings/medications", - "/settings/mood", - ]) { + for (const child of ["/settings/dashboard", "/settings/insights"]) { const html = renderShell({ pathname: child }); const layoutActive = /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; @@ -169,12 +191,63 @@ describe("", () => { it("highlights the Layout hub when the active prop is a Layout child", () => { // The page passes `active={section}` explicitly; a child slug must // still resolve onto the Layout hub for nav highlighting. - const html = renderShell({ active: "mood", pathname: "/settings/mood" }); + const html = renderShell({ + active: "dashboard", + pathname: "/settings/dashboard", + }); const layoutActive = /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; expect(html.match(layoutActive)?.length ?? 0).toBe(2); }); + it("module-gates the Stimmung entry off `user.modules.mood` (v1.18.0 S5)", () => { + // Fail-open default: mood undefined → entry shown. + expect(renderShell({ active: "account" })).toContain( + 'href="/settings/mood"', + ); + // Explicitly disabled → entry hidden. + const disabled = renderShell({ + active: "account", + modules: { mood: false }, + }); + expect(disabled).not.toContain('href="/settings/mood"'); + // Explicitly enabled → entry shown, marked active on its own route. + const enabled = renderShell({ + active: "mood", + pathname: "/settings/mood", + modules: { mood: true }, + }); + const moodActive = + /]*\baria-current="page"[^>]*\bhref="\/settings\/mood"|]*\bhref="\/settings\/mood"[^>]*\baria-current="page"/g; + expect(enabled.match(moodActive)?.length ?? 0).toBe(2); + }); + + it("module-gates the Coach entry off `user.modules.coach` (v1.18.0 S5)", () => { + // Fail-open default: coach undefined → entry shown. + expect(renderShell({ active: "account" })).toContain( + 'href="/settings/coach"', + ); + // Explicitly disabled → entry hidden. + const disabled = renderShell({ + active: "account", + modules: { coach: false }, + }); + expect(disabled).not.toContain('href="/settings/coach"'); + }); + + it("module-gates the Health-record entry off `user.modules.doctorReport` (B3)", () => { + // Fail-open default: doctorReport undefined → entry shown. + expect(renderShell({ active: "account" })).toContain( + 'href="/settings/gesundheitsakte"', + ); + // Explicitly disabled → entry hidden. + const disabled = renderShell({ + active: "account", + modules: { doctorReport: false }, + }); + expect(disabled).not.toContain('href="/settings/gesundheitsakte"'); + }); + it("falls back to `account` when the pathname doesn't match a known slug", () => { const html = renderShell({ pathname: "/settings/totally-bogus" }); const activeLinkRegex = @@ -190,17 +263,21 @@ describe("", () => { // (single-line; the longer "Notification channels" wrapped). The // `/notifications` inbox now shares the "Notifications" label. expect(html).toContain("Notifications"); - // v1.17.1 (F-2) — the four personalization editors (Dashboard, - // Insights, Medications, Mood) are reached through one Layout hub - // entry; the hub itself is the single nav entry for the concept. + // v1.17.1 (F-2) — the dashboard + insights arrangement editors are + // reached through one Layout hub entry. expect(html).toContain('href="/settings/layout"'); expect(html).toContain("Layout & Personalization"); - // The four editors are no longer standalone nav entries. - expect(html).not.toContain('href="/settings/medications"'); - expect(html).not.toContain('href="/settings/mood"'); + // v1.18.0 (S5) — Medications (Medikamente, always shown — a CORE + // domain) and Mood (Stimmung, gated; fail-open mock shows it) are their + // own nav entries now. + expect(html).toContain('href="/settings/medications"'); + expect(html).toContain('href="/settings/mood"'); // The ampersand is HTML-escaped by React SSR — assert on the encoded // form so we don't accidentally match a parser that double-escapes. expect(html).toContain("API & Tokens"); + // v1.18.0 (S5) — the health record is its own top-level entry. + expect(html).toContain('href="/settings/gesundheitsakte"'); + expect(html).toContain("Health record"); // v1.4.16 phase B7: the consolidated Export section is a top-level // entry in the sidebar; the link must be present in both locales. expect(html).toContain('href="/settings/export"'); @@ -208,13 +285,11 @@ describe("", () => { // About is back in the settings nav as the last entry (it also // stays linked from the sidebar user-card dropdown). expect(html).toContain('href="/settings/about"'); - // v1.8.7.1 — Targets and Sources are two separate nav entries again. + // v1.18.0 (S3) — Targets keeps its own nav entry; Sources folded into + // Integrations as a sub-tab, so it is no longer a standalone nav entry. expect(html).toContain('href="/settings/thresholds"'); - expect(html).toContain('href="/settings/sources"'); + expect(html).not.toContain('href="/settings/sources"'); expect(html).toContain("Targets"); - // v1.12.0 — the source-priority entry adopts the canonical - // "Source priority" / "Quellen-Priorität" label (handover §5). - expect(html).toContain("Source priority"); }); it("resolves every section title via the i18n provider — German", () => { @@ -225,17 +300,16 @@ describe("", () => { // compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications` // inbox now shares the "Benachrichtigungen" label. expect(html).toContain("Benachrichtigungen"); - // v1.17.1 (F-2) — the four personalization editors are reached through - // one Layout hub entry ("Layout & Personalisierung"); they are no - // longer standalone German nav entries. + // v1.17.1 (F-2) — the arrangement editors are reached through one + // Layout hub entry ("Layout & Personalisierung"). expect(html).toContain('href="/settings/layout"'); expect(html).toContain("Layout & Personalisierung"); - expect(html).not.toContain('href="/settings/medications"'); - expect(html).not.toContain('href="/settings/mood"'); - // v1.8.7.1 — Targets and Sources are two separate German nav - // entries again: "Zielwerte" and "Quellen". + // v1.18.0 (S5) — Medications + Mood are their own nav entries now. + expect(html).toContain('href="/settings/medications"'); + expect(html).toContain('href="/settings/mood"'); + // v1.18.0 (S3) — Targets keeps its German nav entry ("Zielwerte"); + // Sources folded into Integrations as a sub-tab. expect(html).toContain("Zielwerte"); - expect(html).toContain("Quellen"); // v1.8.7.1 — the AI Insights section is named "KI-Auswertungen" in // German (the "KI" prefix makes the AI nature explicit). expect(html).toContain("KI-Auswertungen"); @@ -245,8 +319,8 @@ describe("", () => { // "Über diese App" (About) is back as the last in-shell nav entry // (the sidebar user-card dropdown keeps its own link too). expect(html).toContain('href="/settings/about"'); - // v1.8.7.1 — both Targets and Sources nav entries are present. - expect(html).toContain('href="/settings/sources"'); + // v1.18.0 (S3) — Sources is no longer a standalone nav entry. + expect(html).not.toContain('href="/settings/sources"'); }); it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => { diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index 751eed6e1..848d95017 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -51,6 +51,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { insurerIkNumber: null, insuranceNumber: null, cycleTrackingEnabled: false, + modules: {}, }; } diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index 6c01150ee..13430adf1 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -54,6 +54,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { insurerIkNumber: null, insuranceNumber: null, cycleTrackingEnabled: false, + modules: {}, }; } diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx index 5b22235c3..aeb554416 100644 --- a/src/components/settings/about-me-section.tsx +++ b/src/components/settings/about-me-section.tsx @@ -324,7 +324,7 @@ export function AboutMeSection({

{t("settings.ai.aboutMe.questionsOpenCoach")} diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index 4d909f1bb..72ce6cdc3 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -53,7 +53,6 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { TimezonePicker } from "@/components/settings/timezone-picker"; import { TimeFormatSelect } from "@/components/settings/time-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; -import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -633,11 +632,9 @@ export function AccountSection() {

- {/* Global injection-site exclusions live with the account - profile — a personal preference that applies app-wide. The - unit system moved into the profile form above, beside the - timezone dropdown. */} - + {/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated + Medikamente settings section, where every medication-specific + preference now lives. */} {/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this lets any account opt in (or opt out) before the gated /cycle page is diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 9fe3b8e6c..d30e8c7e7 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -5,23 +5,24 @@ * * v1.16.4 — the former 2k-LOC monolith is split into per-provider * subcomponents under `src/components/settings/ai/` (one file per - * provider form, plus the chain / runtime / coach-toggle cards). This - * file only owns the section frame and the card order; behaviour lives - * in the parts. + * provider form, plus the chain / runtime cards). This file only owns + * the section frame and the card order; behaviour lives in the parts. + * + * v1.18.0 (S5) — the Coach preference cards (disable toggle, preferences, + * memory) moved out to the dedicated Coach section. The AI section keeps + * only provider / model / BYOK configuration plus the "about me" context + * (which the daily briefing reads too, so it is not Coach-only). */ import { AboutMeSection } from "@/components/settings/about-me-section"; import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card"; -import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card"; -import { CoachMemorySection } from "@/components/settings/coach-memory-section"; -import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; export function AiSection() { const { t } = useTranslations(); - const { isAuthenticated, user } = useAuth(); + const { isAuthenticated } = useAuth(); // v1.16.6 — the auth query can resolve before this boundary // hydrates; every `disabled={!isAuthenticated}` binding in the child // cards would then disagree with the SSR HTML (React #418). Gate the @@ -30,12 +31,6 @@ export function AiSection() { // on the first client re-render. const mounted = useMounted(); const authed = mounted && isAuthenticated; - // v1.11.2 — the Coach-memory panel rides the same gate as the rest of - // the Coach surface: when the user has hidden the Coach there is no - // assistant to remember anything, so the memory controls hide too. - // (`mounted`-gated like `authed`: SSR renders the panels, so the - // hydration render must too, even when the user disabled the Coach.) - const coachEnabled = !mounted || !user?.disableCoach; return (
@@ -50,24 +45,10 @@ export function AiSection() { - {/* v1.4.47 W3 — per-user Coach opt-out. Lives below the provider - card so users who want the surface gone never have to scroll - through provider configuration before finding the toggle. */} - - - {/* v1.16.1 — Coach preferences (tone, verbosity, data scope) - moved here from the in-chat sheet; the chat header gear - deep-links to this section. Gated on the Coach being - enabled. */} - {coachEnabled && } - - {/* v1.11.2 — "What the Coach remembers": durable-fact review + - forget controls. Gated on the Coach being enabled. */} - {coachEnabled && } - {/* v1.15.20 — user-authored "about me" context for the Coach + daily briefing. Not gated on the Coach toggle: the briefing - reads it too. */} + reads it too, so it stays in the AI section rather than the + Coach section. */}
); diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx index 46f204a7c..132f3ed87 100644 --- a/src/components/settings/coach-nudge-card.tsx +++ b/src/components/settings/coach-nudge-card.tsx @@ -2,7 +2,7 @@ /** * v1.15.20 — Settings → Notifications opt-out for the proactive Coach - * nudge (the daily 05:15 cron that points the user at /insights/coach + * nudge (the daily 05:15 cron that points the user at /coach * when a deterministic trigger fires). Default ON; the toggle writes * `notificationPrefs.coach.nudgesEnabled` through the roaming * notification-prefs blob so web + iOS share one source of truth. diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx index 79f855a57..be712b335 100644 --- a/src/components/settings/coach-prefs-section.tsx +++ b/src/components/settings/coach-prefs-section.tsx @@ -218,7 +218,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) { > {t("insights.coach.settingsSourcesPointer")}{" "} {t("insights.coach.settingsSourcesPointerLink")} diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx new file mode 100644 index 000000000..fc9563edf --- /dev/null +++ b/src/components/settings/coach-section.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * v1.18.0 (S5) — Settings → Coach. + * + * The Coach preference cards moved out of the AI / Assistant section into + * their own dedicated entry so the AI section owns only provider / model / + * BYOK configuration. This section gathers the Coach-specific surface: + * + * 1. Disable Coach — the per-user opt-out (kept first so a user who + * wants the surface gone finds it immediately). + * 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`). + * 3. Coach memory — durable-fact review + forget controls. + * + * The nav entry is module-gated on `coach`. The preference + memory cards + * keep their own content gate on `!user.disableCoach` (mirrored from the + * old AI section): hiding the Coach hides its tuning + memory controls too. + * + * The "about me" context lives in the AI section, not here — the daily + * briefing reads it too, so it is not a Coach-only setting. + */ + +import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card"; +import { CoachMemorySection } from "@/components/settings/coach-memory-section"; +import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; + +export function CoachSection() { + const { t } = useTranslations(); + const { isAuthenticated, user } = useAuth(); + // Mirror the AI section's hydration-safe gating: the auth query can + // resolve before this boundary hydrates, so gate the child props on + // `useMounted()` to keep the hydration render matching the SSR HTML. + const mounted = useMounted(); + const authed = mounted && isAuthenticated; + // The preference + memory cards ride the same content gate as the rest + // of the Coach surface: when the user has hidden the Coach there is no + // assistant to tune or remember anything. (`mounted`-gated so the SSR + + // hydration renders agree.) + const coachEnabled = !mounted || !user?.disableCoach; + + return ( +
+
+

+ {t("settings.sections.coach.title")} +

+

+ {t("settings.sections.coach.description")} +

+
+ + + + {coachEnabled && } + + {coachEnabled && } +
+ ); +} diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index d93498f9d..3074d8ded 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -49,6 +49,8 @@ import { SelectValue, } from "@/components/ui/select"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { useAuth } from "@/hooks/use-auth"; +import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/widget-modules"; /** * v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the @@ -149,6 +151,14 @@ const WIDGET_LABEL_KEYS: Record = { export function DashboardLayoutSection({ id }: { id: string }) { const { t } = useTranslations(); const queryClient = useQueryClient(); + // v1.18.0 — a widget toggle whose owning module is disabled is a dead + // control (the snapshot gates the tile/chart out server-side regardless + // of the switch). Read the resolved module map and hide those rows. + // Fail-open: a missing map / absent key (stale /me payload, core widget) + // reads as enabled, so the row always shows unless the module is + // explicitly `false`. + const { user } = useAuth(); + const modules = user?.modules; // v1.4.47 W4 — stable id namespace for the drag-handle `aria-describedby` // tooltip. One hint paragraph is rendered once at the bottom of the list // and referenced by every drag handle in this section. @@ -471,6 +481,14 @@ export function DashboardLayoutSection({ id }: { id: string }) { .filter((w): w is typeof w & { id: DashboardWidgetId } => webWidgetIds.has(w.id), ) + // v1.18.0 — hide a widget toggle whose owning module is + // disabled. Map the widget id → ModuleKey FIRST (undefined = + // core widget = always shown), THEN check the module map. + // Fail-open: only an explicit `false` hides the row. + .filter((w) => { + const moduleKey = WIDGET_MODULE_BY_ID[w.id]; + return !moduleKey || modules?.[moduleKey] !== false; + }) .sort((a, b) => a.order - b.order); const sortedIds = sortedWidgets.map((w) => w.id); return ( diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 6761a8daf..07b0463b0 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -9,18 +9,14 @@ * per export type, each with its own filter inputs and a clear * "Download" / "Generate" button. * - * 1. Health Record export — hero panel at the top of the page - * (PDF + FHIR R4 + zip package, see - * ``) - * 2. Measurements CSV — optional `since`/`until` - * 3. Medications CSV — optional intake-history toggle - * 4. Mood CSV — optional `since`/`until` - * 5. Full JSON Backup — single-file user-scoped dump + * 1. Measurements CSV — optional `since`/`until` + * 2. Medications CSV — optional intake-history toggle + * 3. Mood CSV — optional `since`/`until` + * 4. Full JSON Backup — single-file user-scoped dump * - * v1.12 — the health-record export is the headline "data out" path; it - * takes the page hero. The doctor-report PDF lives under the - * health-record export now and is no longer offered as a separate card - * here (it used to be a small secondary card at the bottom). + * v1.18.0 (S5) — the full health-record export (PDF + FHIR R4 + zip + * package) moved out to its own top-level "Gesundheitsakte" section. This + * page now keeps only the generic data export/import paths. * * Mobile-first: cards stack on `=md`. */ @@ -36,13 +32,14 @@ import { Loader2, Pill, Waves, + type LucideIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DateInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; -import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ImportPanel } from "@/components/settings/import-panel"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; @@ -87,15 +84,9 @@ export function ExportSection() {

- {/* v1.12 — full health-record export is now the page hero. PDF + - FHIR R4 + a combined zip package, with grouped data-section - toggles (collapsed by default) and a format switch. This is the - headline "data out" path; it carries the primary visual weight. */} - - - {/* v1.12 — secondary export options. The four CSV/JSON - destinations live under a dedicated sub-heading. - Mobile: single column. Desktop (md+): 2-column grid. */} + {/* v1.18.0 (S5) — the full health-record export moved to its own + top-level "Gesundheitsakte" section. This page keeps the generic + CSV/JSON data-out paths and the import surface. */}
; + icon: LucideIcon; title: string; description: string; format: ExportFormat; @@ -157,16 +148,16 @@ function ExportCardShell({ data-testid={testId} className={`bg-card border-border flex h-full flex-col rounded-xl border p-4 sm:p-6${outerClassName ? ` ${outerClassName}` : ""}`} > -
-
- -

{title}

-
- - {format} - -
-

{description}

+ + {format} + + } + /> {children &&
{children}
}
{footer}
@@ -231,7 +222,7 @@ function buildQueryString(base: Record): string { interface CsvCardProps { testId: string; actionTestId: string; - icon: React.ComponentType<{ className?: string }>; + icon: LucideIcon; titleKey: string; descriptionKey: string; endpoint: string; diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx new file mode 100644 index 000000000..4a3a9bb3f --- /dev/null +++ b/src/components/settings/gesundheitsakte-section.tsx @@ -0,0 +1,45 @@ +"use client"; + +/** + * v1.18.0 (S5) — Settings → Gesundheitsakte (health record). + * + * The full health-record export was the hero panel at the top of the + * Export & Import section. It is a distinct concept — a complete, + * clinician-ready record (PDF + FHIR R4 + a combined zip package) — and + * earns its own top-level settings home rather than sharing the page with + * the generic CSV / JSON data-out paths. Export & Import now keeps only + * the generic data export/import surfaces. + * + * Module-gated on `doctorReport` (v1.18.0 B3): the nav entry hides when the + * module is off (see `SETTINGS_SECTIONS` in `settings-shell.tsx`), and the + * server-side `/api/export/health-record` route refuses with a 403 + * `module.disabled` envelope — so a disabled doctor-report surface cannot be + * reached over a Bearer token either. + */ + +import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel"; +import { useTranslations } from "@/lib/i18n/context"; + +export function GesundheitsakteSection() { + const { t } = useTranslations(); + return ( +
+
+

+ {t("settings.sections.gesundheitsakte.title")} +

+

+ {t("settings.sections.gesundheitsakte.description")} +

+
+ + +
+ ); +} diff --git a/src/components/settings/health-record-export-panel.tsx b/src/components/settings/health-record-export-panel.tsx index 61e96d18f..ec21299e3 100644 --- a/src/components/settings/health-record-export-panel.tsx +++ b/src/components/settings/health-record-export-panel.tsx @@ -24,6 +24,7 @@ import { useId, useState } from "react"; import { ChevronDown, Download, FileText, Loader2 } from "lucide-react"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -202,21 +203,13 @@ export function HealthRecordExportPanel() { "relative isolate overflow-hidden rounded-xl p-4 sm:p-5", )} > -
-
-

- {t("settings.healthRecord.description")} -

+
{/* Format + range share a row on desktop so the panel opens diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 7484da40f..86e6d5ad0 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -1,234 +1,43 @@ "use client"; -import { useEffect, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; +/** + * `` — Settings → Integrations. + * + * v1.18.0 (S3) — Integrations is now the one home for every delivery / ingest + * connection. Three sub-tabs under one nav entry: + * 1. Connections — Withings / WHOOP / Fitbit / Polar / Oura / Nightscout + * (the OAuth + device data sources). See `connections-panel.tsx`. + * 2. Channels — Telegram / ntfy / Web Push / Webhook / Email plus the live + * per-channel status surface (moved here from Settings → Notifications, + * where it never belonged: channels are delivery providers, not + * reminder preferences). See `notification-channels-panel.tsx`. + * 3. Sources — the per-metric source-priority + device-type ladders that + * decide which connection wins when two report the same metric. Folded in + * from the former standalone `/settings/sources` page. See + * `sources-section.tsx` (sub-tab mode). + * + * `parseOAuthOutcome` / `oauthReasonKey` are re-exported from + * `connections-panel.tsx` so existing unit-test imports keep resolving. + */ -import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; -import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; -import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card"; -import { OuraCard } from "@/components/settings/integrations/oura-card"; -import { PolarCard } from "@/components/settings/integrations/polar-card"; -import { - pickStatus, - useIntegrationStatuses, - type IntegrationStatusViewModel, -} from "@/components/settings/integrations/shared"; -import { WhoopCard } from "@/components/settings/integrations/whoop-card"; -import { WithingsCard } from "@/components/settings/integrations/withings-card"; -import { useAuth } from "@/hooks/use-auth"; +import { useState } from "react"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel"; +import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; +import { SourcesSection } from "@/components/settings/sources-section"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -/** - * The Withings OAuth callback (`/api/withings/callback`) redirects back - * here with `?withings=connected` or `?withings=error&reason=`. - * Map every reason tag the callback emits onto a human-readable i18n - * key (what went wrong + what to do next). Unknown tags fall back to - * the generic entry so a future callback branch never strands the user - * with silent params. - */ -const WITHINGS_OAUTH_ERROR_KEYS: Record = { - csrf1: "settings.withingsOauthError.csrf1", - replay: "settings.withingsOauthError.replay", - state: "settings.withingsOauthError.state", - expired: "settings.withingsOauthError.expired", - cross_user: "settings.withingsOauthError.cross_user", - nocode: "settings.withingsOauthError.nocode", - nocreds: "settings.withingsOauthError.nocreds", - token: "settings.withingsOauthError.token", -}; +export { + parseOAuthOutcome, + oauthReasonKey, +} from "@/components/settings/integrations/connections-panel"; -type WithingsOauthOutcome = - | { kind: "connected" } - | { kind: "error"; reason: string }; - -/** - * The OAuth providers whose callbacks redirect back to the settings page with a - * `?=connected|error&reason=` outcome param. Polar/Oura own a - * per-card status query; WHOOP/Fitbit read off the consolidated envelope — both - * are invalidated on a successful return so the card repaints either way. - */ -const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const; -type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number]; - -const OAUTH_OUTCOME_KEYS: Record< - OAuthOutcomeProvider, - () => readonly unknown[] -> = { - polar: queryKeys.polar, - oura: queryKeys.oura, - whoop: queryKeys.whoop, - fitbit: queryKeys.fitbit, -}; - -/** - * Reason tags the four OAuth callbacks emit. Known tags resolve to a specific - * message; anything else falls back to the provider's `generic` copy. Union of - * the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`). - */ -const OAUTH_OUTCOME_REASONS = new Set([ - "csrf1", - "state", - "cross_user", - "nocode", - "nocreds", - "token", - "rate_limited", - "expired", -]); - -type OAuthOutcome = - | { provider: OAuthOutcomeProvider; kind: "connected" } - | { provider: OAuthOutcomeProvider; kind: "error"; reason: string }; - -/** - * Parse the OAuth-return outcome from a URL query string. Reads the first - * provider whose `?=connected|error` param is present, in - * `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider - * coverage is unit-testable without a browser. - */ -export function parseOAuthOutcome(search: string): OAuthOutcome | null { - const params = new URLSearchParams(search); - for (const provider of OAUTH_OUTCOME_PROVIDERS) { - const v = params.get(provider); - if (v === "connected") return { provider, kind: "connected" }; - if (v === "error") { - return { provider, kind: "error", reason: params.get("reason") ?? "unknown" }; - } - } - return null; -} - -/** - * Resolve the i18n key for an error reason tag. Known tags map to the - * provider-specific message; anything else falls back to `generic`. - */ -export function oauthReasonKey( - provider: OAuthOutcomeProvider, - reason: string, -): string { - return OAUTH_OUTCOME_REASONS.has(reason) - ? `settings.${provider}OauthError.${reason}` - : `settings.${provider}OauthError.generic`; -} - -/** - * Adapt the consolidated-envelope view-model into the OAuth card's status - * shape. Returns `undefined` when the envelope hasn't loaded so the card - * renders its loading/disconnected default rather than a half-populated state. - */ -function toOAuthStatus( - vm: IntegrationStatusViewModel | undefined, -): OAuthProviderStatus | undefined { - if (!vm) return undefined; - return { - connected: vm.connected ?? false, - configured: vm.configured ?? false, - available: vm.available ?? false, - hasOwnCredentials: vm.hasOwnCredentials, - state: vm.state, - lastSuccessAt: vm.lastSuccessAt, - lastAttemptAt: vm.lastAttemptAt, - lastError: vm.lastError, - }; -} +type IntegrationsTab = "connections" | "channels" | "sources"; export function IntegrationsSection() { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); - const router = useRouter(); - const queryClient = useQueryClient(); - - const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); - - // OAuth callback handler — reads `?withings=connected|error&reason=…` - // from the URL (lazy initialiser, same shape as the Codex handler in - // `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the - // callback set these params and nothing ever read them: a user came - // back from Withings onto a silently unchanged settings page. - const [withingsOauthOutcome] = useState(() => { - if (typeof window === "undefined") return null; - const params = new URLSearchParams(window.location.search); - const status = params.get("withings"); - if (status === "connected") return { kind: "connected" }; - if (status === "error") { - return { kind: "error", reason: params.get("reason") ?? "unknown" }; - } - return null; - }); - - useEffect(() => { - if (!withingsOauthOutcome) return; - // Scrub the one-shot params so a reload / bookmark doesn't replay - // the toast. - const url = new URL(window.location.href); - url.searchParams.delete("withings"); - url.searchParams.delete("reason"); - router.replace(`${url.pathname}${url.search}`, { scroll: false }); - if (withingsOauthOutcome.kind === "connected") { - toast.success(t("settings.withingsOauthConnected")); - queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - const reasonKey = - WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ?? - "settings.withingsOauthError.generic"; - toast.error(t("settings.withingsOauthFailed"), { - description: t(reasonKey), - duration: 10_000, - }); - } - }, [withingsOauthOutcome, router, queryClient, t]); - - // v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers. - // The Polar / Oura / WHOOP / Fitbit callbacks redirect back with - // `?=connected` or `?=error&reason=`; surface the - // outcome as a toast and scrub the one-shot params so a reload doesn't replay - // it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a - // WHOOP or Fitbit round-trip landed on a silently unchanged settings page, - // the same gap the Withings handler was written to close. - const [oauthOutcome] = useState(() => { - if (typeof window === "undefined") return null; - return parseOAuthOutcome(window.location.search); - }); - - useEffect(() => { - if (!oauthOutcome) return; - const { provider } = oauthOutcome; - const url = new URL(window.location.href); - url.searchParams.delete(provider); - url.searchParams.delete("reason"); - router.replace(`${url.pathname}${url.search}`, { scroll: false }); - if (oauthOutcome.kind === "connected") { - toast.success(t(`settings.${provider}OauthConnected`)); - // Polar/Oura own a per-card status query; WHOOP/Fitbit read off the - // consolidated envelope — invalidate both so the card repaints either way. - queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.integrationsStatus(), - }); - } else { - // Known reason tags resolve to a specific message; anything else falls - // back to the generic copy (matching the Withings handler). - toast.error(t(`settings.${provider}OauthFailed`), { - description: t(oauthReasonKey(provider, oauthOutcome.reason)), - duration: 10_000, - }); - } - }, [oauthOutcome, router, queryClient, t]); - - const withingsViewModel = pickStatus(integrationStatus, "withings"); - const whoopViewModel = pickStatus(integrationStatus, "whoop"); - const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); - // v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards - // no longer fire their own /api//status round-trip. - const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar")); - const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura")); + const [tab, setTab] = useState("connections"); return (
{t("settings.sections.integrations.description")}

- {/* Cross-link to Settings → Sources: when two integrations (or - an integration + manual entry) report the same metric, the - source-priority ladder decides which value counts — a fact - newcomers otherwise discover only after a confusing chart. */} -

- {t("settings.integrationsSourcesHint")}{" "} - - {t("settings.integrationsSourcesHintLink")} - -

- - - - - - + setTab(value as IntegrationsTab)} + > + + + {t("settings.sections.integrations.tabs.connections")} + + + {t("settings.sections.integrations.tabs.channels")} + + + {t("settings.sections.integrations.tabs.sources")} + + + + + + + + + + + + +
); } diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx new file mode 100644 index 000000000..d7ec368fc --- /dev/null +++ b/src/components/settings/integrations/connections-panel.tsx @@ -0,0 +1,257 @@ +"use client"; + +/** + * `` — the connected-services half of Settings → + * Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Nightscout. + * + * v1.18.0 (S3) — extracted out of `integrations-section.tsx` so the section + * file can host the Connections / Channels / Sources sub-tabs without growing + * unbounded. This panel owns the OAuth-callback toast handlers (the four + * providers + Withings redirect back here with `?=…` params) and the + * card grid. It renders no page header of its own; the parent supplies it. + */ + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; +import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; +import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card"; +import { OuraCard } from "@/components/settings/integrations/oura-card"; +import { PolarCard } from "@/components/settings/integrations/polar-card"; +import { + pickStatus, + useIntegrationStatuses, + type IntegrationStatusViewModel, +} from "@/components/settings/integrations/shared"; +import { WhoopCard } from "@/components/settings/integrations/whoop-card"; +import { WithingsCard } from "@/components/settings/integrations/withings-card"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * The Withings OAuth callback (`/api/withings/callback`) redirects back + * here with `?withings=connected` or `?withings=error&reason=`. + * Map every reason tag the callback emits onto a human-readable i18n + * key (what went wrong + what to do next). Unknown tags fall back to + * the generic entry so a future callback branch never strands the user + * with silent params. + */ +const WITHINGS_OAUTH_ERROR_KEYS: Record = { + csrf1: "settings.withingsOauthError.csrf1", + replay: "settings.withingsOauthError.replay", + state: "settings.withingsOauthError.state", + expired: "settings.withingsOauthError.expired", + cross_user: "settings.withingsOauthError.cross_user", + nocode: "settings.withingsOauthError.nocode", + nocreds: "settings.withingsOauthError.nocreds", + token: "settings.withingsOauthError.token", +}; + +type WithingsOauthOutcome = + | { kind: "connected" } + | { kind: "error"; reason: string }; + +/** + * The OAuth providers whose callbacks redirect back to the settings page with a + * `?=connected|error&reason=` outcome param. Polar/Oura own a + * per-card status query; WHOOP/Fitbit read off the consolidated envelope — both + * are invalidated on a successful return so the card repaints either way. + */ +const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const; +type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number]; + +const OAUTH_OUTCOME_KEYS: Record< + OAuthOutcomeProvider, + () => readonly unknown[] +> = { + polar: queryKeys.polar, + oura: queryKeys.oura, + whoop: queryKeys.whoop, + fitbit: queryKeys.fitbit, +}; + +/** + * Reason tags the four OAuth callbacks emit. Known tags resolve to a specific + * message; anything else falls back to the provider's `generic` copy. Union of + * the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`). + */ +const OAUTH_OUTCOME_REASONS = new Set([ + "csrf1", + "state", + "cross_user", + "nocode", + "nocreds", + "token", + "rate_limited", + "expired", +]); + +type OAuthOutcome = + | { provider: OAuthOutcomeProvider; kind: "connected" } + | { provider: OAuthOutcomeProvider; kind: "error"; reason: string }; + +/** + * Parse the OAuth-return outcome from a URL query string. Reads the first + * provider whose `?=connected|error` param is present, in + * `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider + * coverage is unit-testable without a browser. + */ +export function parseOAuthOutcome(search: string): OAuthOutcome | null { + const params = new URLSearchParams(search); + for (const provider of OAUTH_OUTCOME_PROVIDERS) { + const v = params.get(provider); + if (v === "connected") return { provider, kind: "connected" }; + if (v === "error") { + return { provider, kind: "error", reason: params.get("reason") ?? "unknown" }; + } + } + return null; +} + +/** + * Resolve the i18n key for an error reason tag. Known tags map to the + * provider-specific message; anything else falls back to `generic`. + */ +export function oauthReasonKey( + provider: OAuthOutcomeProvider, + reason: string, +): string { + return OAUTH_OUTCOME_REASONS.has(reason) + ? `settings.${provider}OauthError.${reason}` + : `settings.${provider}OauthError.generic`; +} + +/** + * Adapt the consolidated-envelope view-model into the OAuth card's status + * shape. Returns `undefined` when the envelope hasn't loaded so the card + * renders its loading/disconnected default rather than a half-populated state. + */ +function toOAuthStatus( + vm: IntegrationStatusViewModel | undefined, +): OAuthProviderStatus | undefined { + if (!vm) return undefined; + return { + connected: vm.connected ?? false, + configured: vm.configured ?? false, + available: vm.available ?? false, + hasOwnCredentials: vm.hasOwnCredentials, + state: vm.state, + lastSuccessAt: vm.lastSuccessAt, + lastAttemptAt: vm.lastAttemptAt, + lastError: vm.lastError, + }; +} + +export function ConnectionsPanel() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); + + // OAuth callback handler — reads `?withings=connected|error&reason=…` + // from the URL (lazy initialiser, same shape as the Codex handler in + // `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the + // callback set these params and nothing ever read them: a user came + // back from Withings onto a silently unchanged settings page. + const [withingsOauthOutcome] = useState(() => { + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + const status = params.get("withings"); + if (status === "connected") return { kind: "connected" }; + if (status === "error") { + return { kind: "error", reason: params.get("reason") ?? "unknown" }; + } + return null; + }); + + useEffect(() => { + if (!withingsOauthOutcome) return; + // Scrub the one-shot params so a reload / bookmark doesn't replay + // the toast. + const url = new URL(window.location.href); + url.searchParams.delete("withings"); + url.searchParams.delete("reason"); + router.replace(`${url.pathname}${url.search}`, { scroll: false }); + if (withingsOauthOutcome.kind === "connected") { + toast.success(t("settings.withingsOauthConnected")); + queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + const reasonKey = + WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ?? + "settings.withingsOauthError.generic"; + toast.error(t("settings.withingsOauthFailed"), { + description: t(reasonKey), + duration: 10_000, + }); + } + }, [withingsOauthOutcome, router, queryClient, t]); + + // v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers. + // The Polar / Oura / WHOOP / Fitbit callbacks redirect back with + // `?=connected` or `?=error&reason=`; surface the + // outcome as a toast and scrub the one-shot params so a reload doesn't replay + // it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a + // WHOOP or Fitbit round-trip landed on a silently unchanged settings page, + // the same gap the Withings handler was written to close. + const [oauthOutcome] = useState(() => { + if (typeof window === "undefined") return null; + return parseOAuthOutcome(window.location.search); + }); + + useEffect(() => { + if (!oauthOutcome) return; + const { provider } = oauthOutcome; + const url = new URL(window.location.href); + url.searchParams.delete(provider); + url.searchParams.delete("reason"); + router.replace(`${url.pathname}${url.search}`, { scroll: false }); + if (oauthOutcome.kind === "connected") { + toast.success(t(`settings.${provider}OauthConnected`)); + // Polar/Oura own a per-card status query; WHOOP/Fitbit read off the + // consolidated envelope — invalidate both so the card repaints either way. + queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + // Known reason tags resolve to a specific message; anything else falls + // back to the generic copy (matching the Withings handler). + toast.error(t(`settings.${provider}OauthFailed`), { + description: t(oauthReasonKey(provider, oauthOutcome.reason)), + duration: 10_000, + }); + } + }, [oauthOutcome, router, queryClient, t]); + + const withingsViewModel = pickStatus(integrationStatus, "withings"); + const whoopViewModel = pickStatus(integrationStatus, "whoop"); + const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); + // v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards + // no longer fire their own /api//status round-trip. + const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar")); + const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura")); + + return ( +
+

+ {t("settings.sections.integrations.connections.description")} +

+ + + + + + + +
+ ); +} diff --git a/src/components/settings/integrations/notification-channels-panel.tsx b/src/components/settings/integrations/notification-channels-panel.tsx new file mode 100644 index 000000000..0faf9a4a8 --- /dev/null +++ b/src/components/settings/integrations/notification-channels-panel.tsx @@ -0,0 +1,93 @@ +"use client"; + +/** + * `` — the delivery-provider half of Settings → + * Integrations. + * + * v1.18.0 (S3) — the notification CHANNELS (Telegram / ntfy / Web Push / + * Webhook / Email, plus the live per-channel status surface) moved out of + * Settings → Notifications and into Integrations. Channels are delivery + * providers — the same conceptual family as Withings / WHOOP / a connected + * device — not notification preferences. The reminder-TYPE content (which + * events fire) stays under Settings → Reminders / Notifications. + * + * Renders inside the Integrations "Channels" sub-tab. Owns no page header of + * its own; the parent section supplies it. + */ + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { NotificationStatusCard } from "@/components/settings/notification-status-card"; +import { NtfyCard } from "@/components/settings/ntfy-card"; +import { TelegramCard } from "@/components/settings/telegram-card"; +import { WebPushCard } from "@/components/settings/web-push-card"; +import { WebhookCard } from "@/components/settings/webhook-card"; +import { EmailCard } from "@/components/settings/email-card"; +import { apiGet } from "@/lib/api/api-fetch"; + +interface GlobalServiceAvailability { + telegramGlobal: boolean; + ntfyGlobal: boolean; + webPushGlobal: boolean; + apiGlobal: boolean; + moodLogGlobal: boolean; +} + +export function NotificationChannelsPanel() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data: globalServices } = useQuery({ + queryKey: queryKeys.settingsGlobalServices(), + queryFn: async () => { + return apiGet("/api/settings/global-services"); + }, + enabled: isAuthenticated, + }); + + const showTelegram = globalServices?.telegramGlobal ?? true; + const showNtfy = globalServices?.ntfyGlobal ?? true; + const showWebPush = globalServices?.webPushGlobal ?? true; + + return ( +
+

+ {t("settings.sections.integrations.channels.description")} +

+ + + + {/* v1.4.27 MB3 — `scroll-mt-28` keeps each channel card's anchor + target below the safe-area sticky page header when the user lands + on `/settings/integrations#telegram` or similar. */} + {showTelegram && ( +
+ +
+ )} + {showNtfy && ( +
+ +
+ )} + {showWebPush && ( +
+ +
+ )} + {/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack / + Matrix / Home Assistant in one channel). */} +
+ +
+ {/* v1.17.1 — SMTP / email. The card hides itself when the operator + hasn't configured SMTP_* env, so it never shows a dead toggle. */} +
+ +
+
+ ); +} diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index c9ed1814a..cf4474ec4 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -4,8 +4,6 @@ import Link from "next/link"; import { ChevronRight, LayoutDashboard, - Pill, - Smile, TrendingUp, type LucideIcon, } from "lucide-react"; @@ -16,13 +14,15 @@ import { useTranslations } from "@/lib/i18n/context"; /** * v1.17.1 (F-2) — the one "Layout & Personalization" home. * - * Before this section, "how my app is laid out" was scattered across four - * unrelated settings sections — Dashboard, Insights, Medications, Mood — - * with no shared framing, so the same concept (arrange / reorder / show- - * hide) read as four disconnected places. This section is the single - * front door: it presents the four personalization surfaces as one - * consistent set of cards and links to each editor, which all keep their - * own routes and mutation flows intact. One concept, one home. + * Before this section, "how my app is laid out" was scattered across + * several unrelated settings sections with no shared framing. This + * section is the single front door for the arrangement editors: it + * presents them as one consistent set of cards and links to each editor, + * which keep their own routes and mutation flows intact. + * + * v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung) graduated + * to their own top-level nav entries, so the hub now hosts only the + * dashboard + insights arrangement editors. */ interface LayoutLink { href: string; @@ -44,18 +44,10 @@ const LAYOUT_LINKS: ReadonlyArray = [ titleKey: "settings.sections.layout.insights.title", descriptionKey: "settings.sections.layout.insights.description", }, - { - href: "/settings/medications", - icon: Pill, - titleKey: "settings.sections.layout.medications.title", - descriptionKey: "settings.sections.layout.medications.description", - }, - { - href: "/settings/mood", - icon: Smile, - titleKey: "settings.sections.layout.mood.title", - descriptionKey: "settings.sections.layout.mood.description", - }, + // v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung) graduated + // to their own top-level nav entries, so they are no longer linked from + // the Layout hub. Only the dashboard + insights arrangement editors + // remain here. ]; export function LayoutSection() { diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx index 224d0817a..61dce559d 100644 --- a/src/components/settings/medications-section.tsx +++ b/src/components/settings/medications-section.tsx @@ -8,7 +8,9 @@ import { type ReorderMedication, } from "@/components/medications/medication-order-editor"; import { MedicationViewToggle } from "@/components/medications/medication-view-toggle"; -import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; +import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { applyMedicationOrder } from "@/lib/medications/medication-order"; @@ -16,17 +18,21 @@ import { queryKeys } from "@/lib/query-keys"; import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout"; /** - * v1.16.10 — the "Medikamente" settings section (analogous to the - * Dashboard and Insights sections). Hosts two blocks: + * v1.16.10 — the "Medikamente" settings section. v1.18.0 (S5) promoted it + * from a Layout-hub child to its own standalone nav entry (always shown — + * medications is a CORE domain) and gathered the medication-specific + * preferences here. Hosts three blocks: * 1. The list VIEW preference — cards vs table, written optimistically * through the same `PUT /api/medications/layout` the page header * toggle uses (the toggle component is shared). * 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv) * with drag + arrow reordering, flushed by an explicit Save, * persisted as active-ids-then-inactive-ids on the same layout row. + * 3. Injection-site exclusions — moved here from the account profile so + * every medication-specific preference lives on one screen. * - * Both write through the same `/api/medications/layout` contract the - * /medications page reads, so a save here repaints both list views. + * The list view + order both write through the same `/api/medications/layout` + * contract the /medications page reads, so a save here repaints both views. */ /** The slice of the medications list the order editor needs. */ @@ -39,6 +45,7 @@ interface MedicationListEntry { export function MedicationsSection() { const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); const { layout, isLayoutLoading, setView } = useMedicationListLayout(); const { data: medications, isLoading } = useQuery({ @@ -69,11 +76,7 @@ export function MedicationsSection() { aria-labelledby="settings-section-medications-title" className="space-y-6" > -
- +

{t("settings.sections.medications.title")}

@@ -88,12 +91,10 @@ export function MedicationsSection() { id="medications-view" className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6" > -
- -

- {t("medications.viewToggleLabel")} -

-
+

{t("medications.viewToggleHint")} @@ -111,15 +112,11 @@ export function MedicationsSection() { id="medications-order" className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6" > -

- -

- {t("medications.reorderTitle")} -

-
-

- {t("medications.reorderDescription")} -

+ {isLoading || isLayoutLoading ? (
@@ -129,6 +126,11 @@ export function MedicationsSection() { )}
+ + {/* v1.18.0 (S5) — injection-site exclusions are a medication setting; + they moved here from the account profile so all medication-specific + preferences live in one place. */} + ); } diff --git a/src/components/settings/module-toggle-row.tsx b/src/components/settings/module-toggle-row.tsx new file mode 100644 index 000000000..efa4d0a46 --- /dev/null +++ b/src/components/settings/module-toggle-row.tsx @@ -0,0 +1,140 @@ +"use client"; + +/** + * `` — one row in the Settings → Module ("Was du trackst") + * hub. v1.18.0. + * + * Renders a single module: a neutral icon, the i18n label + one-line + * description, and a `` reflecting the resolved enabled-state. The + * row is presentation-only — the parent `` owns the mutation + * and cache invalidation, so this component stays a pure, easily-tested leaf. + * + * Four modes: + * - toggleable: the Switch is live; flipping it calls `onToggle(next)`. + * - locked (core domains): the Switch is checked + disabled with a short + * "always on" note, so the always-on measurement engine + meds read as + * deliberately fixed rather than broken. + * - managed elsewhere (delegated modules cycle/coach): no Switch at all — + * the real on/off lives in another section, so the row renders a + * read-only deep-link ("Manage in X") rather than a dead toggle that + * no-ops and snaps back behind a misleading "saved" toast. + * - operator-disabled: the operator turned the module off server-wide, so + * the Switch is replaced by a read-only "disabled server-wide" note — + * a per-user toggle could not re-enable it anyway. + * + * Palette stays neutral throughout — no alarming green-when-on / + * red-when-off tint. + */ + +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +export interface ModuleToggleRowProps { + /** Stable key — used for the input id + the test query handle. */ + moduleKey: string; + icon: LucideIcon; + label: string; + description: string; + /** Whether the Switch reads on. Core rows pass `true`. */ + enabled: boolean; + /** + * When set the row is read-only: the Switch is locked checked and the + * `lockedNote` line renders beneath the description. Core domains use this. + */ + locked?: boolean; + /** One-line "core / always on" note, only shown when `locked`. */ + lockedNote?: string; + /** + * Delegated modules (cycle/coach): the real control lives elsewhere. When + * set, the row renders a read-only deep-link instead of a Switch. + * `label` is the destination name; `href` the in-app link. + */ + managedAt?: { href: string; label: string }; + /** Localised "Manage in {section}" link text (managed-elsewhere rows). */ + manageLinkLabel?: string; + /** + * Operator turned this module off server-wide. Replaces the Switch with a + * read-only note; a per-user toggle cannot re-enable it. `operatorNote` + * is the localised "disabled server-wide" copy. + */ + operatorDisabled?: boolean; + operatorNote?: string; + /** Disable the Switch while a mutation is in flight (toggleable rows). */ + pending?: boolean; + /** Fired with the next desired state on a user toggle (toggleable rows). */ + onToggle?: (next: boolean) => void; +} + +export function ModuleToggleRow({ + moduleKey, + icon: Icon, + label, + description, + enabled, + locked = false, + lockedNote, + managedAt, + manageLinkLabel, + operatorDisabled = false, + operatorNote, + pending = false, + onToggle, +}: ModuleToggleRowProps) { + const inputId = `module-toggle-${moduleKey}`; + // A delegated module whose real control lives elsewhere, or an + // operator-disabled module: either way no live Switch — the per-user + // toggle would be a dead no-op. + const managed = managedAt != null; + return ( +
+
+
+ {operatorDisabled ? ( + // Operator kill-switch: read-only note, no toggle. + + {operatorNote} + + ) : managed ? ( + // Delegated module: deep-link to the real control instead of a + // dead toggle. + + {manageLinkLabel} +
+ ); +} diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx new file mode 100644 index 000000000..92259a449 --- /dev/null +++ b/src/components/settings/modules-section.tsx @@ -0,0 +1,205 @@ +"use client"; + +/** + * `` — Settings → Module ("Was du trackst") hub. v1.18.0. + * + * The one place a user decides which secondary domains HealthLog surfaces. + * Each toggleable module (mood, sleep, glucose, workouts, recovery, labs, + * achievements, coach, insights, doctor report, cycle) gets a row with an + * icon, label, one-line description, and a ``. Flipping a switch + * PATCHes `/api/auth/me/modules` (a DISABLED allowlist; the endpoint refuses + * core keys) and then invalidates the `authMe()` query so the nav, Insights + * pills, and dashboard tiles re-gate live off `useAuth().user.modules`. + * + * The four CORE domains (weight, blood pressure, pulse, medications) render + * as a separate read-only "always on" group — locked switches with a short + * note — so the always-on measurement engine + meds read as deliberately + * fixed, never disableable. Palette stays neutral throughout; this is a + * calm configuration surface, not an alarm panel. + * + * State source is `useAuth().user.modules` (the resolved `/auth/me` map). + * A module is enabled unless its key is explicitly `false` — default-on. + */ + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + Activity, + Award, + Blocks, + CalendarHeart, + Droplet, + Dumbbell, + FileText, + HeartPulse, + Lock, + MessageCircleHeart, + Moon, + Pill, + Scale, + Smile, + Sparkles, + TestTube, + type LucideIcon, +} from "lucide-react"; + +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ModuleToggleRow } from "@/components/settings/module-toggle-row"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiPatch } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { + CORE_DOMAIN_KEYS, + MODULE_REGISTRY, + moduleDelegatesTo, + type CoreDomainKey, + type ModuleKey, +} from "@/lib/modules/registry"; + +/** Neutral Lucide glyph per toggleable module. */ +const MODULE_ICONS: Record = { + cycle: CalendarHeart, + mood: Smile, + sleep: Moon, + glucose: Droplet, + workouts: Dumbbell, + recovery: Activity, + labs: TestTube, + achievements: Award, + coach: MessageCircleHeart, + insights: Sparkles, + doctorReport: FileText, +}; + +/** Neutral Lucide glyph per core (always-on) domain. */ +const CORE_ICONS: Record = { + weight: Scale, + bloodPressure: HeartPulse, + pulse: Activity, + medications: Pill, +}; + +export function ModulesSection() { + const { t } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + + const modules = user?.modules ?? {}; + const moduleAvailability = user?.moduleAvailability ?? {}; + + const toggle = useMutation({ + // Factory-routed; the in-repo eslint rule forbids a bare literal here. + mutationKey: queryKeys.modulesPrefs(), + mutationFn: async (vars: { key: ModuleKey; enabled: boolean }) => { + // DISABLED allowlist: send only the single key the user flipped. + // Delegated keys (cycle/coach) never reach here — they render as + // read-only deep-link rows, so the PATCH only ever carries a + // directly-owned key. + return apiPatch("/api/auth/me/modules", { [vars.key]: vars.enabled }); + }, + onSuccess: () => { + // Re-gate every surface that reads the module map off /auth/me: nav, + // Insights pills, dashboard tiles, quick-add, search. + void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }); + toast.success(t("settings.sections.modules.saved")); + }, + onError: () => { + toast.error(t("settings.sections.modules.error")); + }, + }); + + return ( +
+
+

+ {t("settings.sections.modules.title")} +

+

+ {t("settings.sections.modules.description")} +

+
+ + {/* Toggleable modules */} +
+ +
+ {(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => { + const def = MODULE_REGISTRY[key]; + // Default-on: a module is enabled unless explicitly `false`. + const enabled = modules[key] !== false; + // Delegated modules (cycle/coach) are owned elsewhere — never a + // live toggle here; deep-link to their real control instead. + const delegated = moduleDelegatesTo(key) !== undefined; + // Operator turned this module off server-wide: no per-user + // toggle can re-enable it, so show a read-only note. Delegated + // rows already deep-link, so this only matters for owned keys. + const operatorDisabled = + !delegated && moduleAvailability[key] === false; + return ( + toggle.mutate({ key, enabled: next })} + /> + ); + })} +
+
+ + {/* Core domains — always on, read-only */} +
+ +
+ {CORE_DOMAIN_KEYS.map((key) => ( + + ))} +
+
+
+ ); +} diff --git a/src/components/settings/mood-section.tsx b/src/components/settings/mood-section.tsx index 675a75547..f4a251e96 100644 --- a/src/components/settings/mood-section.tsx +++ b/src/components/settings/mood-section.tsx @@ -2,11 +2,11 @@ import { Archive, Folders, Loader2, Tags } from "lucide-react"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ArchivedTagsCard } from "@/components/mood/manage/archived-tags-card"; import { TagGroupsCard } from "@/components/mood/manage/tag-groups-card"; import { TagManagerCard } from "@/components/mood/manage/tag-manager-card"; import { useMoodTagManage } from "@/components/mood/manage/use-mood-tag-manage"; -import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; @@ -45,11 +45,7 @@ export function MoodSection() { aria-labelledby="settings-section-mood-title" className="space-y-6" > -
- +

{t("settings.sections.mood.title")}

@@ -63,12 +59,10 @@ export function MoodSection() { id="mood-groups" className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6" > -
- -

- {t("mood.manage.groupsTitle")} -

-
+ {isLoading || !catalog ? loading : }
@@ -77,12 +71,7 @@ export function MoodSection() { id="mood-tags" className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6" > -
- -

- {t("mood.manage.tagsTitle")} -

-
+ {isLoading || !catalog ? loading : }
@@ -91,12 +80,10 @@ export function MoodSection() { id="mood-archived" className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6" > -
- -

- {t("mood.manage.archivedTitle")} -

-
+ {isLoading || !catalog ? ( loading ) : ( diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 600569109..d57d23b4b 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,133 +1,107 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; -import { ChevronRight } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { LowStockCard } from "@/components/settings/low-stock-card"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; -import { NotificationStatusCard } from "@/components/settings/notification-status-card"; -import { NtfyCard } from "@/components/settings/ntfy-card"; -import { TelegramCard } from "@/components/settings/telegram-card"; -import { WebPushCard } from "@/components/settings/web-push-card"; -import { WebhookCard } from "@/components/settings/webhook-card"; -import { EmailCard } from "@/components/settings/email-card"; -import { apiGet } from "@/lib/api/api-fetch"; - -interface GlobalServiceAvailability { - telegramGlobal: boolean; - ntfyGlobal: boolean; - webPushGlobal: boolean; - apiGlobal: boolean; - moodLogGlobal: boolean; -} +import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; +/** + * `` — Settings → Notifications ("Benachrichtigungen"). + * + * v1.18.0 (S4) — the single module-gated home for reminder TYPES. Earlier the + * settings carried two near-identical entries: this Notifications screen and a + * separate "Erinnerungen" hub that only deep-linked into the canonical editors + * ("doppelt gemoppelt"). The hub is gone (its route 301-redirects here); the + * reminder TYPES now live centrally on this one screen. + * + * Each reminder-type card is shown ONLY when its module is enabled, read from + * the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and + * Insights pills gate off). Mood → `mood`, Coach nudge → `coach`. Low-stock + * runway maps to the medications CORE domain, so it is always shown. Vorsorge + * (preventive-care) reminders are not a toggleable module, so they are always + * shown. The gate fails OPEN: a missing key reads as enabled, so a stale + * `/me` payload never blanks a card. + * + * Delivery CHANNELS (Telegram / ntfy / Web Push / Webhook / Email) are a + * different concept — a channel is a delivery provider, the same family as a + * connected device — and live under Settings → Integrations → Channels (moved + * there in S3). A single concise pointer below the heading links to them. + */ export function NotificationsSection() { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, user } = useAuth(); - const { data: globalServices } = useQuery({ - queryKey: queryKeys.settingsGlobalServices(), - queryFn: async () => { - return apiGet("/api/settings/global-services"); - }, - enabled: isAuthenticated, - }); - - const showTelegram = globalServices?.telegramGlobal ?? true; - const showNtfy = globalServices?.ntfyGlobal ?? true; - const showWebPush = globalServices?.webPushGlobal ?? true; + // v1.18.0 (S4) — module-gated per-type visibility. Fail OPEN (`!== false`) + // so a stale `/me` payload without the module map keeps every card visible. + const moodEnabled = user?.modules?.mood !== false; + const coachEnabled = user?.modules?.coach !== false; return (
- {/* v1.4.33 IW7 — disambiguate the channel-config screen from the - inbox at `/notifications`. Both surfaces used to be named plain - "Notifications", so the crumb spells out where the user is and - offers a one-tap jump to the inbox. */} -
+ {/* sr-only h1 — the visible section title is painted by the settings + shell nav; the in-page heading mirrors every other settings + section's scaffold so the screen reads as one of the family. */}

{t("settings.sections.notifications.title")}

{t("settings.sections.notifications.description")}

+ {/* One concise pointer to the delivery channels (now under + Integrations) and one to the notification inbox — not a duplicated + section, just where-to-go links. */} +

+ {t("settings.sections.notifications.channelsHint")}{" "} + + {t("settings.sections.notifications.channelsHintLink")} + + {" · "} + + {t("settings.sections.notifications.inboxLink")} + +

- + {/* Vorsorge (preventive-care) reminders — always shown; not a + toggleable module. The canonical in-place editor, embedded here so + "what gets reminded and when" reads as one screen. */} + - {/* v1.4.27 MB3 — `scroll-mt-28` keeps each notification card's - anchor target below the safe-area sticky page header when the - user lands on `/settings/notifications#telegram` or similar. - Without the offset the section title pins under the header on - mobile and the user has to scroll back up to find it. */} - {showTelegram && ( -
- + {/* Mood check-in reminder — only when the mood module is enabled. */} + {moodEnabled ? ( +
+
- )} - {showNtfy && ( -
- -
- )} - {showWebPush && ( -
- -
- )} - {/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack / - Matrix / Home Assistant in one channel). */} -
- -
- {/* v1.17.1 — SMTP / email. The card hides itself when the operator - hasn't configured SMTP_* env, so it never shows a dead toggle. */} -
- -
-
- -
- {/* v1.16.11 — medication low-stock runway threshold. */} + ) : null} + + {/* Medication low-stock runway — medications is a CORE domain, so this + is always shown. */}
- {/* v1.15.20 — proactive Coach nudge opt-out. */} -
- -
+ + {/* Proactive Coach nudge — only when the coach module is enabled. */} + {coachEnabled ? ( +
+ +
+ ) : null}
); } diff --git a/src/components/settings/reminders-section.tsx b/src/components/settings/reminders-section.tsx deleted file mode 100644 index 698465751..000000000 --- a/src/components/settings/reminders-section.tsx +++ /dev/null @@ -1,176 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { - Bell, - ChevronRight, - HeartPulse, - MessageCircle, - Package, - Pill, - Smile, - Stethoscope, - type LucideIcon, -} from "lucide-react"; - -import { cn } from "@/lib/utils"; -import { useTranslations } from "@/lib/i18n/context"; - -/** - * v1.17.1 — the one "Reminders & Notifications" home. - * - * Before this section, "what gets reminded and when" was scattered across - * three unrelated homes: medication reminders lived per-medication on the med - * cards, mood + low-stock + coach-nudge lived inside the Notifications channel - * screen, and the new Vorsorge (preventive-care) reminders had their own - * page. There was no single place that answered "was erinnert mich woran, - * wann, und worüber". - * - * This hub is the single front door. It draws a clean line between two - * genuinely different concepts (so the maintainer rule "never split the same - * concept across a page" still holds): - * - * - CHANNELS — the *how/where* a reminder is delivered (APNs / Telegram / - * ntfy / Web Push / Webhook / Email). These keep living on the - * Notifications channel screen; this hub links to them. - * - CATEGORIES — the *what/when*: medication, mood, Vorsorge, low-stock - * runway, and the proactive Coach nudge. Each keeps its own canonical - * editor and deep link; the hub just gathers them so the concept reads - * as one place. - * - * Consolidation by linking — every existing editor stays intact and - * deep-linkable; nothing here is a rewrite. - */ -interface ReminderLink { - href: string; - icon: LucideIcon; - titleKey: string; - descriptionKey: string; - /** External-to-settings link (opens a feature page, not a settings tab). */ - testId: string; -} - -const CHANNEL_LINKS: ReadonlyArray = [ - { - href: "/settings/notifications", - icon: Bell, - titleKey: "settings.sections.reminders.channels.title", - descriptionKey: "settings.sections.reminders.channels.description", - testId: "reminders-link-channels", - }, -]; - -const CATEGORY_LINKS: ReadonlyArray = [ - { - href: "/medications", - icon: Pill, - titleKey: "settings.sections.reminders.medication.title", - descriptionKey: "settings.sections.reminders.medication.description", - testId: "reminders-link-medication", - }, - { - href: "/vorsorge", - icon: Stethoscope, - titleKey: "settings.sections.reminders.vorsorge.title", - descriptionKey: "settings.sections.reminders.vorsorge.description", - testId: "reminders-link-vorsorge", - }, - { - href: "/settings/notifications#mood-reminder", - icon: Smile, - titleKey: "settings.sections.reminders.mood.title", - descriptionKey: "settings.sections.reminders.mood.description", - testId: "reminders-link-mood", - }, - { - href: "/settings/notifications#low-stock", - icon: Package, - titleKey: "settings.sections.reminders.lowStock.title", - descriptionKey: "settings.sections.reminders.lowStock.description", - testId: "reminders-link-low-stock", - }, - { - href: "/settings/notifications#coach-nudge", - icon: MessageCircle, - titleKey: "settings.sections.reminders.coach.title", - descriptionKey: "settings.sections.reminders.coach.description", - testId: "reminders-link-coach", - }, -]; - -function ReminderLinkCard({ link }: { link: ReminderLink }) { - const { t } = useTranslations(); - const Icon = link.icon; - return ( -
  • - -
  • - ); -} - -export function RemindersSection() { - const { t } = useTranslations(); - - return ( -
    -
    -

    - {t("settings.sections.reminders.title")} -

    -

    - {t("settings.sections.reminders.description")} -

    -
    - - {/* CATEGORIES — the what/when. */} -
    -
    -
    -
      - {CATEGORY_LINKS.map((link) => ( - - ))} -
    -
    - - {/* CHANNELS — the how/where. */} -
    -
    -
    -
      - {CHANNEL_LINKS.map((link) => ( - - ))} -
    -
    -
    - ); -} diff --git a/src/components/settings/section-placeholder.tsx b/src/components/settings/section-placeholder.tsx index e437f3b95..5dec2f479 100644 --- a/src/components/settings/section-placeholder.tsx +++ b/src/components/settings/section-placeholder.tsx @@ -12,12 +12,13 @@ */ import { - AlarmClock, Bell, + Blocks, + Bot, Download, + FileHeart, Info, KeyRound, - Layers, LayoutDashboard, Link2, Pill, @@ -36,18 +37,19 @@ import type { SettingsSectionSlug } from "./section-slugs"; const SLUG_ICON: Record = { account: User, + modules: Blocks, integrations: Link2, notifications: Bell, - reminders: AlarmClock, layout: LayoutDashboard, dashboard: LayoutDashboard, insights: TrendingUp, medications: Pill, mood: Smile, thresholds: SlidersHorizontal, - sources: Layers, ai: Sparkles, + coach: Bot, api: KeyRound, + gesundheitsakte: FileHeart, export: Download, advanced: Settings2, about: Info, diff --git a/src/components/settings/section-slugs.ts b/src/components/settings/section-slugs.ts index 87e9723b3..33ee0ff6c 100644 --- a/src/components/settings/section-slugs.ts +++ b/src/components/settings/section-slugs.ts @@ -6,13 +6,12 @@ * graph. */ -// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are again two -// separate settings pages, each its own left-nav entry and route. They were -// merged into one "Targets & Sources" page in v1.4.34 IW-D, but the two -// editors drive distinct mutation flows (`/api/user/thresholds` vs -// `/api/auth/me/source-priority`) and reading them as one page made the -// surface long; splitting them back keeps each concern self-contained. -// Both slugs are served by the dynamic `[section]` route below. +// v1.18.0 (S3) — `sources` is no longer a standalone slug. Source priority +// folded into Settings → Integrations as the "Sources" sub-tab (deciding which +// connection wins when two report the same metric is an Integrations concern). +// `/settings/sources` 301-redirects to `/settings/integrations` (next.config.ts). +// `thresholds` (Targets) keeps its own left-nav entry + route, served by the +// dynamic `[section]` route below. // v1.16.10 — `medications` sits after `insights`: the customise surface // for the /medications list (view preference + manual order), following // the dashboard / insights pattern of a dedicated settings section @@ -27,25 +26,34 @@ // `layout` entry instead of four scattered "arrange" entries, so the // concept reads as one place. The four editor slugs stay in this list so // their routes still resolve; they are simply not listed in the shell nav. -// v1.17.1 — `reminders` sits right after `notifications`: the one -// "Reminders & Notifications" home that gathers the scattered reminder -// categories (medication, mood, Vorsorge, low-stock, coach nudge) and links -// to the notification channels. The canonical editors keep their own routes; -// the hub is consolidation by linking. +// v1.18.0 (S4) — the standalone `reminders` hub is gone. It was a +// link-only page ("doppelt gemoppelt") that merely deep-linked into the +// canonical editors; reminder TYPES now live centrally in `notifications`, +// each row gated on its module. `/settings/reminders` 301-redirects to +// `/settings/notifications` (next.config.ts). +// v1.18.0 (S5) — per-submodule settings split. `gesundheitsakte` (the full +// health-record export, always available) lifts out of Export & Import into +// its own top-level entry. export const SETTINGS_SECTION_SLUGS = [ "account", + // v1.18.0 — `modules` ("Was du trackst") sits right after account: the + // single front door for enabling/disabling secondary tracking domains. + "modules", "integrations", "notifications", - "reminders", "layout", "dashboard", "insights", "medications", "mood", "thresholds", - "sources", "ai", + // v1.18.0 (S5) — `coach` gathers the Coach preference cards (disable + // toggle, preferences, memory) out of the AI section, module-gated on + // the coach module. The `ai` entry keeps provider / model / BYOK only. + "coach", "api", + "gesundheitsakte", "export", "advanced", "about", diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 28d2c168d..9e773d6eb 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -18,16 +18,19 @@ import * as React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { - AlarmClock, Bell, + Blocks, + Bot, Download, + FileHeart, Info, KeyRound, - Layers, LayoutDashboard, Link2, + Pill, Settings2, SlidersHorizontal, + Smile, Sparkles, User, type LucideIcon, @@ -35,7 +38,9 @@ import { import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleKey } from "@/lib/modules/registry"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, @@ -60,6 +65,14 @@ interface SettingsSection { /** i18n key under `settings.sections..title`. */ titleKey: string; icon: LucideIcon; + /** + * v1.18.0 (S5) — per-submodule entries are listed only when their module + * is enabled. When set, the nav entry hides if the resolved + * `useAuth().user.modules` map has the key explicitly `false`. The gate + * fails OPEN (a missing key reads as enabled) so a stale `/me` payload + * never blanks an entry. Omitted = always shown (global / CORE entries). + */ + moduleGate?: ModuleKey; } /** @@ -73,32 +86,33 @@ interface SettingsSection { * settings navigation. It is now both a regular (last) shell entry and * a user-card dropdown item ("Über HealthLog" / "About HealthLog"). * - * v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two - * separate sidebar entries again (merged into a single "Targets & - * Sources" page in v1.4.34 IW-D, split back here). Both are served by - * the dynamic `[section]` route. + * v1.18.0 (S3) — `sources` is no longer a sidebar entry: source priority + * folded into Settings → Integrations as the "Sources" sub-tab. + * `thresholds` (Targets) keeps its own entry, served by the dynamic + * `[section]` route. */ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ { slug: "account", titleKey: "settings.sections.account.title", icon: User }, + // v1.18.0 — the "Was du trackst" hub. Sits right below Account: the one + // place to enable/disable the secondary tracking domains. + { + slug: "modules", + titleKey: "settings.sections.modules.title", + icon: Blocks, + }, { slug: "integrations", titleKey: "settings.sections.integrations.title", icon: Link2, }, + // v1.18.0 (S4) — "Benachrichtigungen" is now the single module-gated + // reminder-types home. The separate "Erinnerungen" hub (a link-only page) + // is gone; reminder TYPES live here, each row gated on its module. { slug: "notifications", titleKey: "settings.sections.notifications.title", icon: Bell, }, - // v1.17.1 — the one "Reminders & Notifications" home. Gathers the - // scattered reminder categories (medication / mood / Vorsorge / low-stock - // / coach nudge) and links to the notification channels. The canonical - // editors keep their own routes; this nav entry is the single front door. - { - slug: "reminders", - titleKey: "settings.sections.reminders.title", - icon: AlarmClock, - }, // v1.17.1 (F-2) — one "Layout & Personalization" nav entry replaces the // four scattered Dashboard / Insights / Medications / Mood "arrange" // entries. Those routes still resolve (deep links, page-header cogs, and @@ -109,18 +123,50 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.layout.title", icon: LayoutDashboard, }, + // v1.18.0 (S5) — Medikamente: medication-specific settings (list view + + // order + injection sites). Medications is a CORE domain, so the entry is + // always shown. Was a hidden child of the Layout hub. + { + slug: "medications", + titleKey: "settings.sections.medications.title", + icon: Pill, + }, + // v1.18.0 (S5) — Stimmung: the mood-tag management surface gets its own + // nav entry, shown only when the mood module is enabled. Was a hidden + // child of the Layout hub. + { + slug: "mood", + titleKey: "settings.sections.mood.title", + icon: Smile, + moduleGate: "mood", + }, { slug: "thresholds", titleKey: "settings.sections.thresholds.title", icon: SlidersHorizontal, }, - { - slug: "sources", - titleKey: "settings.sections.sources.title", - icon: Layers, - }, { slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles }, + // v1.18.0 (S5) — Coach: the Coach preference cards get their own entry, + // shown only when the coach module is enabled. The AI entry above keeps + // provider / model / BYOK configuration. + { + slug: "coach", + titleKey: "settings.sections.coach.title", + icon: Bot, + moduleGate: "coach", + }, { slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound }, + // v1.18.0 (S5) — the full health record (PDF + FHIR R4 + zip package) + // earns its own home, lifted out of Export & Import. Gated on the + // `doctorReport` module: when the user turns the doctor-report surface off, + // the entry hides (the server-side `/api/export/health-record` gate is the + // hard enforcement; this hides the entry-point to match). + { + slug: "gesundheitsakte", + titleKey: "settings.sections.gesundheitsakte.title", + icon: FileHeart, + moduleGate: "doctorReport", + }, { slug: "export", titleKey: "settings.sections.export.title", @@ -148,15 +194,17 @@ export interface SettingsShellProps { children: React.ReactNode; } -// v1.17.1 (F-2) — the four personalization editors live under the Layout +// v1.17.1 (F-2) — the personalization editors live under the Layout // hub. They keep their own routes but are NOT standalone nav entries, so // when the user is on one of them the Layout nav entry is the one that // reads active. +// +// v1.18.0 (S5) — `mood` (Stimmung) and `medications` (Medikamente) +// graduated to their own nav entries, so they are no longer Layout-hub +// children for highlighting purposes. const LAYOUT_CHILD_SLUGS: ReadonlySet = new Set([ "dashboard", "insights", - "medications", - "mood", ]); /** Map a Layout child editor onto the Layout hub for nav highlighting. */ @@ -181,8 +229,21 @@ function deriveActiveSlug( export function SettingsShell({ active, children }: SettingsShellProps) { const pathname = usePathname(); const { t } = useTranslations(); + const { user } = useAuth(); const activeSlug = deriveActiveSlug(pathname, active); + // v1.18.0 (S5) — per-submodule entries are listed only when their module + // is enabled. Read from the resolved `useAuth().user.modules` map (the + // same map the Module hub, nav, and Insights pills gate off). Fail OPEN + // (`!== false`): a missing key, or a not-yet-resolved `/me` payload, + // reads as enabled so an entry never silently disappears. Entries with + // no `moduleGate` (global / CORE) are always shown. + const modules = user?.modules; + const visibleSections = SETTINGS_SECTIONS.filter( + (section) => + !section.moduleGate || modules?.[section.moduleGate] !== false, + ); + // v1.4.33 IW4 — keep the active chip in view inside the horizontal // mobile strip. On a 393 CSS px viewport the strip is wider than the // viewport and the rightmost chips sit off-screen; a user who tapped @@ -244,7 +305,7 @@ export function SettingsShell({ active, children }: SettingsShellProps) { className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden" >
      - {SETTINGS_SECTIONS.map((section) => { + {visibleSections.map((section) => { const isActive = section.slug === activeSlug; const Icon = section.icon; return ( @@ -279,7 +340,7 @@ export function SettingsShell({ active, children }: SettingsShellProps) { >
        - {SETTINGS_SECTIONS.map((section) => { + {visibleSections.map((section) => { const isActive = section.slug === activeSlug; const Icon = section.icon; return ( diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index eb5b44310..e490ae727 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -111,18 +111,31 @@ const DEVICE_TYPE_LABEL_KEYS: Record = { unknown: "settings.sections.sources.deviceLabels.unknown", }; +export interface SourcesSectionProps { + /** + * `"standalone"` (the default) renders the full page header + the + * cross-link back to Integrations — the shape used while Sources had its + * own `/settings/sources` route. `"subtab"` (v1.18.0 S3) suppresses both: + * Sources now renders inside the Integrations → Sources sub-tab, so the + * page header is the parent section's and a cross-link back to the page + * that already contains it would be circular. + */ + variant?: "standalone" | "subtab"; +} + /** - * `` — route-level wrapper for `/settings/sources` - * (the "Sources" / "Quellen" page). + * `` — the per-metric source-priority + device-type ladders. * - * v1.8.7.1 — Sources is its own settings page again (it was merged into - * the combined "Targets & Sources" page under `/settings/thresholds` in - * v1.4.34 IW-D). The section renders its own page header (h1 + subtitle) - * and owns the per-metric source-priority + device-type ladders. + * v1.18.0 (S3) — Sources folded into Settings → Integrations as the "Sources" + * sub-tab (`variant="subtab"`), since deciding which connection wins when two + * report the same metric is an Integrations concern. The standalone + * `/settings/sources` route is gone; `/settings/sources` 301-redirects to + * `/settings/integrations` (see `next.config.ts`). */ -export function SourcesSection() { +export function SourcesSection({ variant = "standalone" }: SourcesSectionProps = {}) { const { t } = useTranslations(); const queryClient = useQueryClient(); + const isSubtab = variant === "subtab"; const { data: remote, isLoading } = useQuery({ queryKey: queryKeys.sourcePriority(), @@ -285,26 +298,35 @@ export function SourcesSection() { aria-labelledby="settings-section-sources-title" className="space-y-6" > -
        -

        - {t("settings.sections.sources.title")} -

        + {isSubtab ? ( + // Sub-tab mode: the Integrations section owns the page header, and a + // cross-link back to the page that already contains this tab would be + // circular. Surface only the one-line description.

        {t("settings.sections.sources.description")}

        - {/* Cross-link back to Settings → Integrations — the page that - actually adds / removes the sources this ladder ranks. */} -

        - {t("settings.sections.sources.integrationsHint")}{" "} - - {t("settings.sections.sources.integrationsHintLink")} - -

        -
        + ) : ( +
        +

        + {t("settings.sections.sources.title")} +

        +

        + {t("settings.sections.sources.description")} +

        + {/* Cross-link back to Settings → Integrations — the page that + actually adds / removes the sources this ladder ranks. */} +

        + {t("settings.sections.sources.integrationsHint")}{" "} + + {t("settings.sections.sources.integrationsHintLink")} + +

        +
        + )}
        ({ + useRouter: () => ({ push: pushMock }), +})); + +const useAuthMock = vi.fn(); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => useAuthMock(), +})); + +import { useModulePageGuard } from "../use-module-page-guard"; + +function Probe({ moduleKey }: { moduleKey: "glucose" }) { + const { ready } = useModulePageGuard(moduleKey); + return {String(ready)}; +} + +function readyOf(): string { + const html = renderToStaticMarkup(); + return html.includes(">true<") ? "true" : "false"; +} + +describe("useModulePageGuard", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("is not ready while auth is loading", () => { + useAuthMock.mockReturnValue({ + user: null, + isLoading: true, + isAuthenticated: false, + }); + expect(readyOf()).toBe("false"); + }); + + it("is not ready when unauthenticated", () => { + useAuthMock.mockReturnValue({ + user: null, + isLoading: false, + isAuthenticated: false, + }); + expect(readyOf()).toBe("false"); + }); + + it("is not ready when the module is disabled (explicit false)", () => { + useAuthMock.mockReturnValue({ + user: { modules: { glucose: false } }, + isLoading: false, + isAuthenticated: true, + }); + expect(readyOf()).toBe("false"); + }); + + it("is ready when authenticated and the module is enabled", () => { + useAuthMock.mockReturnValue({ + user: { modules: { glucose: true } }, + isLoading: false, + isAuthenticated: true, + }); + expect(readyOf()).toBe("true"); + }); + + it("is ready (default-on) when the module key is absent", () => { + useAuthMock.mockReturnValue({ + user: { modules: {} }, + isLoading: false, + isAuthenticated: true, + }); + expect(readyOf()).toBe("true"); + }); +}); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 246a805fa..fefbbc258 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -9,6 +9,7 @@ import { retryOnceOnTransientError } from "@/lib/queries/retry-transient"; import { useTranslations } from "@/lib/i18n/context"; import type { TimeFormatPreference } from "@/lib/format-locale"; import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format"; +import type { ModuleKey } from "@/lib/modules/registry"; export interface AuthUser { id: string; @@ -80,6 +81,31 @@ export interface AuthUser { * stale /me payload (older server image without the field) in `fetchMe`. */ cycleTrackingEnabled: boolean; + /** + * v1.18.0 — the resolved per-user module enable/disable map. Each + * toggleable module key (mood, sleep, glucose, workouts, recovery, labs, + * achievements, coach, insights, doctorReport, cycle) is `true` when the + * module is enabled for this account, `false` when disabled. `cycle` and + * `coach` are delegated server-side (gender + opt-in / operator flag + + * opt-out) and already reflected here, so nav + Insights pill gates read + * this map rather than re-deriving. Coerced to `{}` against a stale /me + * payload (older server image without the field) so every gate fails open + * — a missing map never blanks the nav. Optional so existing `AuthUser` + * test fixtures stay valid; `fetchMe` always populates it (empty map when + * absent), so live code reads a real map. + */ + modules?: Partial>; + /** + * v1.18.0 — operator-layer availability per toggleable module. `false` + * ⇒ the operator disabled the module server-wide (off for every account, + * regardless of personal preference). Distinct from `modules`, which is + * the already-AND-ed effective state and cannot tell operator-off from + * user-off apart. Only the Modules hub needs this distinction (to render + * an operator-disabled module as a read-only "disabled server-wide" row); + * every other gate reads `modules`. Coerced to `{}` against a stale /me + * payload so a missing map reads as all-available. + */ + moduleAvailability?: Partial>; } async function fetchMe(): Promise { @@ -117,6 +143,18 @@ async function fetchMe(): Promise { // v1.15.0 — coerce against a stale /me payload so the cycle nav entry // stays hidden by default when the field is absent. cycleTrackingEnabled: data.cycleTrackingEnabled === true, + // v1.18.0 — coerce against a stale /me payload (older server image + // without the module map) to an empty map so every module gate fails + // open: a missing key reads as enabled and the nav stays intact. + modules: + data.modules && typeof data.modules === "object" ? data.modules : {}, + // v1.18.0 — operator availability map, coerced against a stale payload + // to an empty map (all-available) so the Modules hub never shows a + // spurious "disabled server-wide" row on an older server image. + moduleAvailability: + data.moduleAvailability && typeof data.moduleAvailability === "object" + ? data.moduleAvailability + : {}, }; } diff --git a/src/hooks/use-module-enabled.ts b/src/hooks/use-module-enabled.ts new file mode 100644 index 000000000..fd4d6bf3e --- /dev/null +++ b/src/hooks/use-module-enabled.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useAuth } from "@/hooks/use-auth"; +import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** + * v1.18.0 R4 — SSR-safe accessor for a per-account module flag. + * + * Mirrors `useDisableCoach()`: `useAuth()` calls `useQuery` directly and + * crashes when a legacy presentational component (``, the + * isolated score-card snapshots, …) renders without a + * ``. We gate the inner hook on + * `useQueryClientMounted()` and short-circuit to the default-on result + * when no client is in the tree — the branch is stable across the + * component's lifetime, so the conditional call is safe per the Rules of + * Hooks. + * + * Default-on (disabled allowlist): an absent map, a missing key, or any + * non-`false` value reads as enabled — matching the server gate in + * `src/lib/modules/gate.ts`. + */ +export function useModuleEnabled(moduleKey: ModuleKey): boolean { + const hasClient = useQueryClientMounted(); + if (!hasClient) return true; + // eslint-disable-next-line react-hooks/rules-of-hooks + return useModuleEnabledInner(moduleKey); +} + +function useModuleEnabledInner(moduleKey: ModuleKey): boolean { + const { user } = useAuth(); + return user?.modules?.[moduleKey] !== false; +} diff --git a/src/hooks/use-module-page-guard.ts b/src/hooks/use-module-page-guard.ts new file mode 100644 index 000000000..758d908f4 --- /dev/null +++ b/src/hooks/use-module-page-guard.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import { useAuth } from "@/hooks/use-auth"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** + * v1.18.0 B1 — client-side page guard for a toggleable module. + * + * Mirrors the cycle page (`src/app/cycle/page.tsx`): an unauthenticated + * visitor bounces to login; an authenticated account without the module + * enabled bounces to `/insights`. The nav entry is already hidden for a + * disabled account, so this only catches a direct URL hit. Every backing + * `/api/*` route enforces the gate server-side, so this is a UX redirect, + * not the security boundary. + * + * Returns `ready` — `true` only once auth has resolved, the visitor is + * authenticated, AND the module is enabled. While `false` the caller + * should render a calm loader / nothing rather than the (about-to- + * redirect) module surface, so a disabled-module page never half-renders. + * + * Default-on (disabled allowlist): an absent map, a missing key, or any + * non-`false` value reads as enabled — matching the server gate. + */ +export function useModulePageGuard(moduleKey: ModuleKey): { + ready: boolean; +} { + const { user, isLoading, isAuthenticated } = useAuth(); + const router = useRouter(); + + const enabled = user?.modules?.[moduleKey] !== false; + + useEffect(() => { + if (isLoading) return; + if (!isAuthenticated) { + router.push("/auth/login"); + } else if (!enabled) { + router.push("/insights"); + } + }, [isLoading, isAuthenticated, enabled, router]); + + return { ready: !isLoading && isAuthenticated && enabled }; +} diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 9c3bfc1f1..fd08a5d0e 100644 Binary files a/src/lib/__tests__/doctor-report-data.test.ts and b/src/lib/__tests__/doctor-report-data.test.ts differ diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 5b0dcd089..68cc6494f 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -439,3 +439,52 @@ describe("doctor-report-pdf-core type-map coverage", () => { expect(text).toContain("Oxygen saturation"); }); }); + +// ── clinical glucose panel ── the server-computed TIR / GMI / eA1C panel must +// render in the PDF whenever glucose readings exist, and stay absent otherwise. +describe("doctor-report clinical glucose panel", () => { + function denseReadings(): { measuredAt: Date; mgdl: number }[] { + const out: { measuredAt: Date; mgdl: number }[] = []; + for (let d = 0; d < 60; d += 1) { + for (let h = 0; h < 24; h += 1) { + out.push({ + measuredAt: new Date( + FIXED_NOW.getTime() - d * 86_400_000 - h * 3_600_000, + ), + mgdl: 110 + ((d + h) % 7) * 8, + }); + } + } + return out; + } + + it("renders the clinical panel title + TIR/GMI rows when readings exist", async () => { + const data = makeData({ + glucoseClinical: computeGlucoseClinicalMetrics(denseReadings(), { + now: FIXED_NOW, + windowDays: 90, + }), + }); + expect(data.glucoseClinical.readingCount).toBeGreaterThan(0); + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("Glucose overview (clinical)"); + expect(text).toContain("Time in range"); + expect(text).toContain("Glucose Management Indicator"); + }); + + it("omits the clinical panel when there are no glucose readings", async () => { + const data = makeData(); // empty (zero-reading) glucose panel + const bytes = renderDoctorReportPdfBytes(data, { + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).not.toContain("Glucose overview (clinical)"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index 97ba155be..96820f0d4 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -22,6 +22,32 @@ vi.mock("@/lib/insights/features", () => ({ extractFeatures: vi.fn(), })); +// v1.18.0 — the snapshot now resolves the per-user module map; stub it so the +// default fixture is "all modules on" (legacy behaviour) without the real gate. +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn(), + // v1.18.0 — the coach cycle block gates through + // `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back it + // with the same resolved map the tests drive via `resolveModuleMap`. + isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => { + const key = args[1]; + const map = await (resolveModuleMap as unknown as () => Promise< + Record + >)(); + return map[key] !== false; + }), +})); +import { resolveModuleMap } from "@/lib/modules/gate"; +const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType< + typeof vi.fn +>; +const allModulesEnabled = (): Record => + Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as Record< + ModuleKey, + boolean + >; + // Capture annotate calls so the budgeting assertions can prove the new // observability events fire. const annotateCalls: Array<{ @@ -148,6 +174,7 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => { vi.clearAllMocks(); annotateCalls.length = 0; __resetCoachSnapshotCacheForTests(); + resolveModuleMapMock.mockResolvedValue(allModulesEnabled()); const measurementRows = denseMeasurementRows(); prismaMock.measurement.findMany.mockImplementation( (args: { where?: { type?: { in?: string[] } | string } }) => { diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 2b692a2bc..99aa27a74 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -33,6 +33,32 @@ const prismaMock = prisma as unknown as { }; const featuresMock = extractFeatures as unknown as ReturnType; +// v1.18.0 — the snapshot now resolves the per-user module map; stub it so the +// default fixture is "all modules on" (legacy behaviour) without the real gate. +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn(), + // v1.18.0 — the coach cycle block gates through + // `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back it + // with the same resolved map the tests drive via `resolveModuleMap`. + isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => { + const key = args[1]; + const map = await (resolveModuleMap as unknown as () => Promise< + Record + >)(); + return map[key] !== false; + }), +})); +import { resolveModuleMap } from "@/lib/modules/gate"; +const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType< + typeof vi.fn +>; +const allModulesEnabled = (): Record => + Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as Record< + ModuleKey, + boolean + >; + /** * v1.4.23 W4 F6 — pins the Apple Health timeline blocks the Coach * snapshot must ship when the user has HealthKit data and explicitly @@ -60,6 +86,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () => // v1.4.33 — `buildCoachSnapshot` now memoises results in-process // for 60 s; reset between tests so each fixture is read fresh. __resetCoachSnapshotCacheForTests(); + resolveModuleMapMock.mockResolvedValue(allModulesEnabled()); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); prismaMock.medication.findMany.mockResolvedValue([]); @@ -344,6 +371,7 @@ describe("buildCoachSnapshot — WHOOP-native day strain (v1.17.0)", () => { beforeEach(() => { vi.clearAllMocks(); __resetCoachSnapshotCacheForTests(); + resolveModuleMapMock.mockResolvedValue(allModulesEnabled()); prismaMock.moodEntry.findMany.mockResolvedValue([]); prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); prismaMock.medication.findMany.mockResolvedValue([]); diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 57856c54e..102241c92 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -22,6 +22,39 @@ vi.mock("@/lib/insights/features", () => ({ extractFeatures: vi.fn(), })); +// v1.18.0 — the snapshot resolves the per-user module map at build start +// so a disabled data-domain module's data never enters the coach context. +// Mock the gate's `resolveModuleMap` so the default fixture is "all +// modules on" (the legacy behaviour) and individual tests can flip a +// single module off without standing up the real gate's DB reads. +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; + +const allModulesEnabled = (): Record => + Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as Record< + ModuleKey, + boolean + >; + +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn(), + // v1.18.0 — the coach cycle block now gates through + // `isCycleAvailableForUser` → `isModuleEnabled(userId, "cycle")`. Back the + // mock with the same resolved map the tests already drive via + // `resolveModuleMap`, so flipping `cycle` off in a fixture also closes the + // cycle block (operator/user kill-switch parity) without a DB read. + isModuleEnabled: vi.fn(async (...args: [string, ModuleKey]) => { + const key = args[1]; + const map = await (resolveModuleMap as unknown as () => Promise< + Record + >)(); + return map[key] !== false; + }), +})); +import { resolveModuleMap } from "@/lib/modules/gate"; +const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType< + typeof vi.fn +>; + // The rolling-profile memory block reads its own persisted sources // (period narrative + band transitions); stub it out here so the // query-count + cache assertions below stay scoped to the core snapshot @@ -30,6 +63,18 @@ vi.mock("../memory-snapshot", () => ({ buildCoachMemoryBlock: vi.fn().mockResolvedValue(null), })); +// v1.18.0 — the recovery composites (derived block + trajectory) are read +// through these builders. Stub them so the recovery-disable test can +// assert they are never invoked (the disabled module pays no read cost); +// their own assembly is covered in derived-snapshot.test.ts / +// trajectory-snapshot.test.ts. +vi.mock("../derived-snapshot", () => ({ + buildDerivedSnapshotBlock: vi.fn().mockResolvedValue(null), +})); +vi.mock("../trajectory-snapshot", () => ({ + buildTrajectorySnapshotBlock: vi.fn().mockResolvedValue(null), +})); + import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; import { @@ -79,6 +124,8 @@ describe("buildCoachSnapshot", () => { // v1.4.23 H4 — snapshot now reads `User.coachPrefsJson` to apply // per-user excludeMetrics. Default null = use legacy defaults. prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); + // v1.18.0 — default fixture: every toggleable module enabled. + resolveModuleMapMock.mockResolvedValue(allModulesEnabled()); featuresMock.mockResolvedValue({ bloodPressure: undefined, weight: undefined, @@ -522,6 +569,158 @@ describe("buildCoachSnapshot", () => { const parsed = JSON.parse(out.snapshotJson); expect(parsed.sleepRhythm).toBeUndefined(); }); + + // ── v1.18.0 module enable/disable — disabled domains never reach the + // coach context ─────────────────────────────────────────────────────── + // + // When a toggleable data-domain module is disabled for the account, the + // snapshot must exclude that domain entirely — the source drops from + // `scope.sources`, no block is emitted, and the provenance metric is + // absent. The gate's resolved map is authoritative (delegations already + // resolved), so the snapshot folds it into the same SYSTEM-side + // exclusion the user's `excludeMetrics` flow already drives. + + it("drops the mood domain from the snapshot when the mood module is disabled", async () => { + resolveModuleMapMock.mockResolvedValue({ + ...allModulesEnabled(), + mood: false, + }); + featuresMock.mockResolvedValue({ + mood: { avg30: 4.2, coverage: { count: 12 } }, + }); + prismaMock.moodEntry.findMany.mockResolvedValue([ + { + moodLoggedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), + score: 4, + }, + ]); + + const out = await buildCoachSnapshot("user-1", { sources: ["mood"] }); + const parsed = JSON.parse(out.snapshotJson); + + // No mood block, no mood source in scope, no mood metric in provenance. + expect(parsed.mood).toBeUndefined(); + expect(parsed.scope.sources).not.toContain("mood"); + expect(out.provenance.metrics).not.toContain("mood"); + // The disabled domain's table is never even read. + expect(prismaMock.moodEntry.findMany).not.toHaveBeenCalled(); + }); + + it("drops the glucose domain when the glucose module is disabled", async () => { + resolveModuleMapMock.mockResolvedValue({ + ...allModulesEnabled(), + glucose: false, + }); + featuresMock.mockResolvedValue({}); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 105, "BLOOD_GLUCOSE"), + daysAgo(4, 98, "BLOOD_GLUCOSE"), + ]); + + const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] }); + const parsed = JSON.parse(out.snapshotJson); + + expect(parsed.glucose).toBeUndefined(); + expect(parsed.scope.sources).not.toContain("glucose"); + expect(out.provenance.metrics).not.toContain("glucose"); + }); + + it("drops the sleep + sleepRhythm blocks when the sleep module is disabled", async () => { + resolveModuleMapMock.mockResolvedValue({ + ...allModulesEnabled(), + sleep: false, + }); + // 14 nights that would otherwise build both the sleep block and a + // ready sleepRhythm DTO. + const rows = Array.from({ length: 14 }, (_, i) => { + const dd = String(1 + i).padStart(2, "0"); + return { + type: "SLEEP_DURATION", + value: 360 + (i % 3) * 20, + measuredAt: new Date(`2026-06-${dd}T06:00:00Z`), + sleepStage: "ASLEEP" as const, + source: null, + deviceType: null, + }; + }); + prismaMock.measurement.findMany.mockResolvedValue(rows); + featuresMock.mockResolvedValue({ + context: { heightCm: null, ageYears: 40, gender: null }, + }); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + timezone: "UTC", + }); + + const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] }); + const parsed = JSON.parse(out.snapshotJson); + + expect(parsed.sleep).toBeUndefined(); + expect(parsed.sleepRhythm).toBeUndefined(); + expect(parsed.scope.sources).not.toContain("sleep"); + expect(out.provenance.metrics).not.toContain("sleep"); + }); + + it("drops the recovery composites when the recovery module is disabled, even with sleep on", async () => { + // Recovery off but sleep on. `derivedActive` reads the sleep signal, + // so without an explicit recovery gate the derived / dayStrain / + // trajectory blocks would still build off sleep alone. + resolveModuleMapMock.mockResolvedValue({ + ...allModulesEnabled(), + recovery: false, + }); + const { buildDerivedSnapshotBlock } = await import("../derived-snapshot"); + const { buildTrajectorySnapshotBlock } = await import( + "../trajectory-snapshot" + ); + + prismaMock.measurement.findMany.mockResolvedValue([]); + featuresMock.mockResolvedValue({ + context: { heightCm: 180, ageYears: 40, gender: "MALE" }, + }); + + const out = await buildCoachSnapshot("user-1", { + sources: ["sleep", "hrv", "resting_hr", "vo2_max"], + }); + const parsed = JSON.parse(out.snapshotJson); + + // No recovery composites reach the prompt. + expect(parsed.derived).toBeUndefined(); + expect(parsed.dayStrain).toBeUndefined(); + expect(parsed.trajectory).toBeUndefined(); + // The recovery source tokens are stripped from scope. + for (const src of ["hrv", "resting_hr", "vo2_max"]) { + expect(parsed.scope.sources).not.toContain(src); + } + // The derived / trajectory readers are never even called — the + // disabled module pays no read cost. + expect(buildDerivedSnapshotBlock).not.toHaveBeenCalled(); + expect(buildTrajectorySnapshotBlock).not.toHaveBeenCalled(); + }); + + it("keeps an enabled domain present while a sibling module is disabled", async () => { + // glucose off, weight on — weight must still flow through. + resolveModuleMapMock.mockResolvedValue({ + ...allModulesEnabled(), + glucose: false, + }); + featuresMock.mockResolvedValue({ + weight: { latest: 80, coverage: { count: 4 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 80.0, "WEIGHT"), + ]); + + const out = await buildCoachSnapshot("user-1", { + sources: ["weight", "glucose"], + }); + const parsed = JSON.parse(out.snapshotJson); + + expect(parsed.weight).toBeDefined(); + expect(parsed.scope.sources).toContain("weight"); + expect(out.provenance.metrics).toContain("weight"); + expect(parsed.scope.sources).not.toContain("glucose"); + }); }); // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index cb0d7ac39..3f2402b35 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -47,7 +47,8 @@ import { buildDerivedSnapshotBlock } from "./derived-snapshot"; import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; -import { isCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; +import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { buildComplianceLedgerRows, buildComplianceMedicationContext, @@ -150,6 +151,46 @@ const CORE_CLUSTERS: ReadonlySet = new Set([ "glucose", ]); +/** + * v1.18.0 — module enable/disable → coach-snapshot domain map. + * + * When a toggleable data-domain module is disabled for the account, the + * domains it owns must never enter the coach context. We reuse the + * existing `excludeMetrics` filtering path (the `excluded` set narrows + * `sources` before any row is read) by folding the disabled modules into + * a SYSTEM-side exclusion that unions with the user's `excludeMetrics`. + * + * Each toggleable data domain maps to the `CoachScopeSource` token(s) + * its snapshot block(s) gate on: + * - `mood` → the mood block (`mood` source). + * - `sleep` → the per-night sleep block + the sleep-rhythm block + * (both gate on the `sleep` source). + * - `glucose` → the glucose per-context + clinical block. + * - `workouts` → the workouts block. + * - `recovery` → the recovery / strain composites. These are the + * derived block (READINESS / RECOVERY_SCORE / STRAIN_SCORE + * / …), the WHOOP-native dayStrain block, and the + * trajectory block — all gated on `derivedActive`, which + * reads HRV / resting-HR / VO₂max. Dropping those source + * tokens drops the raw additive timelines too; the + * composites are additionally gated below so they never + * build off the sleep signal alone. + * + * `cycle` is intentionally absent: its block already resolves through the + * fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user + * toggle AND the operator server-wide kill-switch) below, exactly as the + * W1 foundation prescribes. `coach` is the surface being narrated, not + * a data domain. `labs` / `achievements` / `insights` / `doctorReport` + * own no coach-snapshot data domain. + */ +const MODULE_EXCLUDED_SOURCES: Partial> = { + mood: ["mood"], + sleep: ["sleep"], + glucose: ["glucose"], + workouts: ["workouts"], + recovery: ["hrv", "resting_hr", "vo2_max"], +}; + const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; function windowToDays(window: CoachScopeWindow): number { @@ -501,6 +542,13 @@ async function buildCoachSnapshotImpl( // the request omits an explicit `scope.sources`, the resolved scope // expands the user's saved `dataClusters` (legacy default when the // key is absent). So the prefs read must precede `resolveScope`. + // v1.18.0 — resolve the per-user module map once at build start so a + // disabled data-domain module's data never enters the coach context. + // The map read is memoised per-request by the gate, and runs alongside + // the prefs read (both only need `userId`), so the cold path pays a + // single extra round-trip at most. Disabled modules fold into the same + // SYSTEM-side exclusion the user's `excludeMetrics` flow already drives. + const moduleMapPromise = resolveModuleMap(userId); const prefsRow = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -533,7 +581,26 @@ async function buildCoachSnapshotImpl( ); const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE; const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null); + // v1.18.0 — fold disabled data-domain modules into the system exclusion. + // `moduleMap[key] === false` means the user turned that module off; the + // gate has already resolved every delegation (cycle/coach) so this map + // is authoritative. We union the disabled modules' owned sources into + // `excluded` so the existing source-narrowing path below removes them + // before any row is read — the model never sees a disabled domain. + const moduleMap = await moduleMapPromise; + const recoveryDisabled = moduleMap.recovery === false; const excluded = new Set(prefs.excludeMetrics); + for (const [key, srcs] of Object.entries(MODULE_EXCLUDED_SOURCES)) { + if (moduleMap[key as ModuleKey] === false) { + for (const src of srcs ?? []) { + // Every entry in MODULE_EXCLUDED_SOURCES is a CoachScopeSource that + // also exists in the CoachExcludeMetric enum overlap the + // source-narrowing loop checks against; the cast mirrors the one the + // loop already uses below. + excluded.add(src as unknown as CoachExcludeMetric); + } + } + } // v1.4.36 W3 T2 — `medications` and `anthropometrics` are // exclude-only toggles (not in `CoachScopeSource`); they gate the // GLP-1 weeklyContext / compliance branch and the anthropometrics @@ -789,7 +856,13 @@ async function buildCoachSnapshotImpl( : null, heightCm: derivedCtx?.heightCm ?? null, }; - const derivedActive = derivedSources.some((s) => sources.has(s)); + // v1.18.0 — the derived block + WHOOP-native dayStrain + trajectory are + // the `recovery` module's domain (READINESS / RECOVERY_SCORE / STRAIN / + // …). They are gated on `derivedActive`, which still reads the `sleep` + // signal — so when `recovery` is disabled but `sleep` stays on, gate them + // off explicitly here, not just by dropping the recovery source tokens. + const derivedActive = + !recoveryDisabled && derivedSources.some((s) => sources.has(s)); const moodRowsPromise = wantsMood && features.mood @@ -965,15 +1038,16 @@ async function buildCoachSnapshotImpl( now, coachLocale, ); - // v1.15 — cycle/phase block, gated on the resolved cycle toggle so a + // v1.15 — cycle/phase block, gated on the resolved cycle module so a // non-cycle account issues no query (the helper short-circuits to null // before any read for a disabled account). The block is descriptive only — // current phase + day-of-cycle, the next predicted event (period range, // fertile window goal-gated), and the headline phase-correlation finding. - const cycleEnabled = isCycleEnabled( - prefsRow?.gender, - prefsRow?.cycleProfile ?? null, - ); + // v1.18.0 — the gate is the FULLY-resolved cycle module + // (`isCycleAvailableForUser` → the per-user toggle AND the operator + // server-wide kill-switch), so an operator-off instance never injects the + // cycle block into the coach prompt. + const cycleEnabled = await isCycleAvailableForUser(userId); const cycleBlockPromise = cycleEnabled ? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz) : null; diff --git a/src/lib/analytics/__tests__/compliance-bundle.test.ts b/src/lib/analytics/__tests__/compliance-bundle.test.ts index 519975a8f..0fae41d2d 100644 --- a/src/lib/analytics/__tests__/compliance-bundle.test.ts +++ b/src/lib/analytics/__tests__/compliance-bundle.test.ts @@ -17,6 +17,7 @@ import { buildMedicationComplianceBundle, buildComplianceDisplay, calculateCompliance, + dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, tallyComplianceFromLedger, tallyLedgerRows, @@ -44,6 +45,21 @@ function dailySchedule(): ComplianceSchedule { }; } +function weeklyMondaySchedule(): ComplianceSchedule { + return { + windowStart: "08:00", + windowEnd: "09:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + }; +} + function rollingSchedule(intervalDays: number): ComplianceSchedule { return { windowStart: "10:00", @@ -205,6 +221,104 @@ describe("buildMedicationComplianceBundle — parity with the per-window composi }); }); +describe("dailyComplianceRatesFromLedger — cadence-aware per-day series", () => { + it("weekly Monday-only med: per-day series agrees with compliance30 and never marks non-due days", () => { + // NOW (2026-06-01) is a Monday; build five trailing Mondays and take + // every one on time. A weekly-cadence med expects a dose ONLY on Mondays. + const createdAt = new Date(NOW.getTime() - 60 * DAY_MS); + const schedules = [weeklyMondaySchedule()]; + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + null, + TZ, + ); + + const events: FixtureEvent[] = []; + for (let week = 0; week < 5; week++) { + const daysAgo = week * 7; // every Monday back from NOW + events.push({ + takenAt: slotAt(daysAgo, "08:05"), + skipped: false, + scheduledFor: slotAt(daysAgo, "08:00"), + }); + } + ctx.lastIntakeAt = lastNonSkippedTakenAt(events); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + // Took every expected Monday → 100% over 30 days. + expect(bundle.compliance30.rate).toBe(100); + + // The per-day series carves the same 30-day window out of the shared + // ledger and must agree with compliance30 — no off-cadence weekday is + // counted as a miss. + const from = new Date(NOW.getTime() - 30 * DAY_MS); + const windowRows = bundle.ledgerRows.filter( + (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), + ); + const series = dailyComplianceRatesFromLedger(windowRows); + + // Only the Mondays in the window produce a point — Tuesday…Sunday emit + // nothing, so a 7×-denser daily denominator can never drag the rate down. + // NOW is itself a Monday, so the 30-day window holds five Mondays. + expect(series.length).toBe(5); + for (const day of series) { + expect(day.rate).toBe(100); + expect(day.missed).toBe(0); + } + + // The series' mean rate equals the canonical compliance30 rate: the two + // are computed from the SAME ledger and cannot contradict each other. + const meanRate = + series.reduce((sum, d) => sum + d.rate, 0) / series.length; + expect(Math.round(meanRate)).toBe(bundle.compliance30.rate); + }); + + it("weekly med with one missed Monday: the missed day reads 0%, due days only", () => { + const createdAt = new Date(NOW.getTime() - 60 * DAY_MS); + const schedules = [weeklyMondaySchedule()]; + const ctx = buildComplianceMedicationContext( + { startsOn: null, endsOn: null, oneShot: false, createdAt }, + null, + TZ, + ); + + // Four Mondays in the 30-day window; skip the take on the 2-weeks-ago one. + const events: FixtureEvent[] = []; + for (let week = 0; week < 5; week++) { + const daysAgo = week * 7; + if (week === 2) continue; // forgotten dose → an expected slot reads missed + events.push({ + takenAt: slotAt(daysAgo, "08:05"), + skipped: false, + scheduledFor: slotAt(daysAgo, "08:00"), + }); + } + ctx.lastIntakeAt = lastNonSkippedTakenAt(events); + + const bundle = buildMedicationComplianceBundle(events, schedules, ctx, NOW); + + const from = new Date(NOW.getTime() - 30 * DAY_MS); + const windowRows = bundle.ledgerRows.filter( + (r) => r.at.getTime() >= from.getTime() && r.at.getTime() <= NOW.getTime(), + ); + const series = dailyComplianceRatesFromLedger(windowRows); + + // Still one point per due Monday — never a point on a non-due weekday. + // Five Mondays in the window; the skipped take leaves one as a miss. + expect(series.length).toBe(5); + const zeroDays = series.filter((d) => d.rate === 0); + expect(zeroDays.length).toBe(1); + expect(zeroDays[0].missed).toBe(1); + + // Aggregate taken / (taken + missed) across due days equals compliance30. + const taken = series.reduce((sum, d) => sum + d.taken, 0); + const missed = series.reduce((sum, d) => sum + d.missed, 0); + const aggregateRate = Math.round((taken / (taken + missed)) * 100); + expect(aggregateRate).toBe(bundle.compliance30.rate); + }); +}); + describe("tallyLedgerRows — windowed tallies over one shared ledger", () => { it("the unwindowed tally equals tallyComplianceFromLedger byte-for-byte", () => { const createdAt = new Date(NOW.getTime() - 40 * DAY_MS); diff --git a/src/lib/analytics/__tests__/health-score-fast-path.test.ts b/src/lib/analytics/__tests__/health-score-fast-path.test.ts index 14ae10efc..ace458c43 100644 --- a/src/lib/analytics/__tests__/health-score-fast-path.test.ts +++ b/src/lib/analytics/__tests__/health-score-fast-path.test.ts @@ -667,6 +667,127 @@ describe("computeUserHealthScoreFastPath", () => { }); }); + /** + * v1.18.0 R4 — module-aware mood pillar. With the mood module disabled + * the helper must NOT read mood, must null-weight the mood pillar, and + * must re-normalise the surviving pillars so the score isn't penalised + * (or inflated) by a pillar the user can no longer see. Golden fixture: + * an identical profile with strong, stable mood scored mood-on vs + * mood-off must produce a different, correctly re-weighted score. + */ + describe("module-aware mood pillar (R4)", () => { + // A profile with BP + steady high mood (low variance → high + // stability) + nothing else, so the mood pillar's contribution is + // unambiguous. Six entries clears the 5-entry stability floor. + function steadyMood(now: Date) { + return Array.from({ length: 6 }, (_v, i) => ({ + score: 4, + moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), + })); + } + + it("weights the mood pillar when moodEnabled is omitted (pre-v1.18.0 default)", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + const now = new Date("2026-06-07T12:00:00.000Z"); + MOOD_FIND_MANY.mockResolvedValue(steadyMood(now)); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-mood-on", + bpInTargetPct: 60, + bpGradedScore: 60, + heightCm: null, + now, + coverage, + }); + + expect(result).not.toBeNull(); + // Mood read fired and the pillar carries a non-zero weight. + expect(MOOD_FIND_MANY).toHaveBeenCalled(); + expect(result?.components.mood.value).not.toBeNull(); + expect(result?.components.mood.weight).toBeGreaterThan(0); + }); + + it("drops the mood pillar and re-normalises the score when moodEnabled is false", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + const now = new Date("2026-06-07T12:00:00.000Z"); + // Even if mood rows existed they must be ignored — the helper + // should never issue the read when the module is off. + MOOD_FIND_MANY.mockResolvedValue(steadyMood(now)); + + const result = await computeUserHealthScoreFastPath({ + userId: "user-mood-off", + bpInTargetPct: 60, + bpGradedScore: 60, + heightCm: null, + now, + coverage, + moodEnabled: false, + }); + + expect(result).not.toBeNull(); + // The disabled module is off the round-trip budget entirely. + expect(MOOD_FIND_MANY).not.toHaveBeenCalled(); + // Mood pillar null-weighted; surviving pillar (BP) absorbs the + // freed weight via proportional redistribution. + expect(result?.components.mood.value).toBeNull(); + expect(result?.components.mood.weight).toBe(0); + expect(result?.components.bp.weight).toBe(1); + // BP is the only present pillar → score is the BP graded value. + expect(result?.score).toBe(60); + }); + + it("golden: mood-on vs mood-off yield different, correctly re-weighted scores", async () => { + const coverage = new Map(); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + const now = new Date("2026-06-07T12:00:00.000Z"); + + // Mood-ON: BP=60 (0.30) + perfect-stability mood=100 (0.20). + // present base weights = 0.50 → bp 0.6, mood 0.4 → + // 60*0.6 + 100*0.4 = 36 + 40 = 76. + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + MOOD_FIND_MANY.mockResolvedValue( + // Constant score → coefficient of variation 0 → stability 100. + Array.from({ length: 6 }, (_v, i) => ({ + score: 5, + moodLoggedAt: new Date(now.getTime() - i * 24 * 60 * 60 * 1000), + })), + ); + const onResult = await computeUserHealthScoreFastPath({ + userId: "user-golden-on", + bpInTargetPct: 60, + bpGradedScore: 60, + heightCm: null, + now, + coverage, + }); + + // Mood-OFF: only BP=60 survives → score 60. + MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + const offResult = await computeUserHealthScoreFastPath({ + userId: "user-golden-off", + bpInTargetPct: 60, + bpGradedScore: 60, + heightCm: null, + now, + coverage, + moodEnabled: false, + }); + + expect(onResult?.score).toBe(76); + expect(offResult?.score).toBe(60); + // The mood-off score is NOT silently dragged toward 0 by a phantom + // pillar — it equals the honest single-pillar BP score. + expect(offResult?.score).not.toBe(onResult?.score); + }); + }); + /** * v1.15.12 A1 — the graded clinical-proximity score, when supplied, * becomes the BP pillar VALUE; the binary in-target rate only gates diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 54b7a73aa..dc89a9dce 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -52,6 +52,7 @@ import { type RecurrenceContext, type ScheduleType, } from "@/lib/medications/scheduling/recurrence"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; export interface IntakeEvent { takenAt: Date | null; @@ -1836,6 +1837,85 @@ export function buildMedicationComplianceBundle( return { compliance7, compliance30, complianceDisplay, ledgerRows, ledgerFrom }; } +/** + * One day of the cadence-aware compliance series: the day's compliance % + * over the doses the schedule actually expected that day, plus the raw + * taken / missed counts behind it. + */ +export interface DailyComplianceRate { + /** The day's anchor instant (the first expected slot of the day). */ + date: Date; + /** round(100 · taken / (taken + missed)), capped at 100. */ + rate: number; + /** Doses taken (on-time + late) on the day. */ + taken: number; + /** Expected doses never acted on past their miss cutoff, on the day. */ + missed: number; +} + +/** + * v1.18.0 — collapse unified dose-history ledger rows into a cadence-aware + * per-day compliance series. + * + * Only days the schedule's cadence actually expected a dose produce a point: + * the series is grouped over the ledger's scheduled `slot` rows, so an + * off-cadence weekday (a weekly Monday-only med on a Tuesday) or an off-week + * (the off-week of a bi-weekly schedule) emits no point at all and therefore + * can never be read as a 0% "miss". Each day's rate uses the SAME + * taken / (taken + missed) tally — and the SAME on-time-plus-late numerator, + * skip / ad-hoc / upcoming exclusions — that {@link tallyLedgerRows} applies + * to `compliance7` / `compliance30`, so the per-day series and the window + * rates are computed from one ledger and cannot contradict each other. + * + * `ad_hoc` rows (off-schedule top-ups, no defensible slot) and `upcoming` + * rows (the window hasn't opened) are dropped before grouping, exactly as the + * window tally excludes them. A day whose only ledger rows are skips yields no + * point (zero denominator) rather than a misleading 0%. + */ +export function dailyComplianceRatesFromLedger( + ledgerRows: DoseHistoryRow[], +): DailyComplianceRate[] { + const byDay = new Map< + string, + { taken: number; missed: number; date: Date } + >(); + + for (const row of ledgerRows) { + // Only scheduled slots have a defensible denominator; ad-hoc top-ups are + // excluded from the rate exactly as `tallyLedgerRows` excludes them. + if (row.kind !== "slot") continue; + const isTaken = + row.status === "taken_on_time" || row.status === "taken_late"; + const isMissed = row.status === "missed"; + // `skipped` (deliberate) and `upcoming` (window not open) advance neither + // counter — they never enter the day's denominator. + if (!isTaken && !isMissed) continue; + + const dayKey = toBerlinDayKey(row.at); + const bucket = byDay.get(dayKey) ?? { taken: 0, missed: 0, date: row.at }; + if (isTaken) bucket.taken += 1; + else bucket.missed += 1; + // Keep the earliest slot instant of the day as the point's anchor. + if (row.at.getTime() < bucket.date.getTime()) bucket.date = row.at; + byDay.set(dayKey, bucket); + } + + return Array.from(byDay.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map((day) => { + const denom = day.taken + day.missed; + return { + date: day.date, + rate: + denom > 0 + ? Math.min(100, Math.round((day.taken / denom) * 100)) + : 100, + taken: day.taken, + missed: day.missed, + }; + }); +} + /** * The cadence-timeline construction from {@link calculateCompliance}, * extracted so {@link buildMedicationComplianceBundle} can build it once diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index b6bb3fb1b..eb1f461eb 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -136,6 +136,19 @@ export interface HealthScoreFastPathInput { * the probe cost stays flat in the fan-out. */ coverage?: RollupCoverageMap; + /** + * v1.18.0 R4 — module-aware mood pillar. When the caller resolves the + * `mood` module as disabled it threads `moodEnabled: false` and the + * helper drops the 0.20 mood-stability pillar entirely: the mood read + * is skipped and the series stays empty, so `moodStability` returns + * `null` and the existing proportional null-redistribution re-scales + * the surviving pillars to sum to 100. Without this a disabled-mood + * account is silently penalised (or rewarded) by a pillar it can no + * longer see anywhere else in the product. Omit (or pass `true`) to + * keep the pre-v1.18.0 always-weighted behaviour — the field is + * additive so legacy callers are unchanged. + */ + moodEnabled?: boolean; } /** @@ -150,6 +163,10 @@ export async function computeUserHealthScoreFastPath( ): Promise { const DAY_MS = 24 * 60 * 60 * 1000; const { userId, bpInTargetPct, heightCm, now } = input; + // v1.18.0 R4 — default-on so omitting the flag preserves the + // pre-v1.18.0 always-weighted mood pillar. Only an explicit `false` + // (the caller resolved `mood` as a disabled module) drops the pillar. + const moodEnabled = input.moodEnabled !== false; // v1.4.38 — prior-week BP fallback. When the caller omits the field // we pin to the current value (pre-v1.4.38 behaviour) so the helper // stays a drop-in replacement for legacy callers; when the caller @@ -294,16 +311,25 @@ export async function computeUserHealthScoreFastPath( select: { measuredAt: true, source: true }, orderBy: { measuredAt: "asc" }, }), - prisma.moodEntry.findMany({ - where: { - userId, - // v1.7.0 sync — exclude tombstoned rows. - deletedAt: null, - moodLoggedAt: { gte: prevSince30d, lte: now }, - }, - select: { score: true, moodLoggedAt: true }, - orderBy: { moodLoggedAt: "asc" }, - }), + // v1.18.0 R4 — skip the read entirely when the mood module is off. + // An empty series feeds `moodStability` → null → the 0.20 pillar + // weight redistributes across the surviving pillars. Skipping the + // query (rather than reading-then-discarding) keeps the disabled + // module off the round-trip budget too. + moodEnabled + ? prisma.moodEntry.findMany({ + where: { + userId, + // v1.7.0 sync — exclude tombstoned rows. + deletedAt: null, + moodLoggedAt: { gte: prevSince30d, lte: now }, + }, + select: { score: true, moodLoggedAt: true }, + orderBy: { moodLoggedAt: "asc" }, + }) + : Promise.resolve( + [] as Array<{ score: number; moodLoggedAt: Date }>, + ), prisma.medication.findMany({ // v1.16.11 — as-needed (PRN) medications carry no expected doses // and are excluded from every compliance rate: they must diff --git a/src/lib/cycle/__tests__/gate.test.ts b/src/lib/cycle/__tests__/gate.test.ts index df6dd2266..5f0fe6eff 100644 --- a/src/lib/cycle/__tests__/gate.test.ts +++ b/src/lib/cycle/__tests__/gate.test.ts @@ -1,9 +1,28 @@ /** * v1.15.0 — cycle-tracking feature gate resolver. + * v1.18.0 — the route guard + the server-side availability helper now AND + * in the operator server-wide kill-switch via the module foundation. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; -import { isCycleEnabled, CYCLE_DISABLED_ERROR_CODE } from "../gate"; +const isModuleEnabledMock = vi.fn<(userId: string, key: string) => Promise>(); +const getOrCreateCycleProfileMock = vi.fn(); + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: (userId: string, key: string) => + isModuleEnabledMock(userId, key), +})); +vi.mock("@/lib/cycle/profile", () => ({ + getOrCreateCycleProfile: (...args: unknown[]) => + getOrCreateCycleProfileMock(...args), +})); + +import { + isCycleEnabled, + isCycleAvailableForUser, + requireCycleEnabled, + CYCLE_DISABLED_ERROR_CODE, +} from "../gate"; describe("isCycleEnabled", () => { it("derives from gender when the toggle is null", () => { @@ -39,3 +58,59 @@ describe("isCycleEnabled", () => { expect(CYCLE_DISABLED_ERROR_CODE).toBe("cycle.disabled"); }); }); + +describe("isCycleAvailableForUser (operator AND user — v1.18.0)", () => { + beforeEach(() => { + isModuleEnabledMock.mockReset(); + getOrCreateCycleProfileMock.mockReset(); + }); + + it("delegates to the cycle ModuleKey so the operator kill-switch is AND-ed in", async () => { + isModuleEnabledMock.mockResolvedValue(false); + await expect(isCycleAvailableForUser("u1")).resolves.toBe(false); + expect(isModuleEnabledMock).toHaveBeenCalledWith("u1", "cycle"); + }); + + it("reports available when the module gate resolves true", async () => { + isModuleEnabledMock.mockResolvedValue(true); + await expect(isCycleAvailableForUser("u1")).resolves.toBe(true); + }); +}); + +describe("requireCycleEnabled (route guard — v1.18.0)", () => { + beforeEach(() => { + isModuleEnabledMock.mockReset(); + getOrCreateCycleProfileMock.mockReset(); + getOrCreateCycleProfileMock.mockResolvedValue({ + id: "cp-1", + cycleTrackingEnabled: true, + }); + }); + + it("passes through the profile when the resolved module is enabled", async () => { + isModuleEnabledMock.mockResolvedValue(true); + const result = await requireCycleEnabled("u1", "FEMALE"); + expect(result.enabled).toBe(true); + if (result.enabled) { + expect(result.profile).toMatchObject({ id: "cp-1" }); + } + expect(isModuleEnabledMock).toHaveBeenCalledWith("u1", "cycle"); + }); + + it("returns a 403 cycle.disabled envelope when the operator turned the module off", async () => { + // Per-user toggle is on (profile.cycleTrackingEnabled = true) — the only + // thing closing the gate is the operator server-wide kill-switch routed + // through the module foundation. + isModuleEnabledMock.mockResolvedValue(false); + const result = await requireCycleEnabled("u1", "FEMALE"); + expect(result.enabled).toBe(false); + if (!result.enabled) { + expect(result.response.status).toBe(403); + const body = (await result.response.json()) as { + error: string | null; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe(CYCLE_DISABLED_ERROR_CODE); + } + }); +}); diff --git a/src/lib/cycle/gate.ts b/src/lib/cycle/gate.ts index 8f4ce1062..839ff1e02 100644 --- a/src/lib/cycle/gate.ts +++ b/src/lib/cycle/gate.ts @@ -5,12 +5,22 @@ * (a) the per-user `CycleProfile.cycleTrackingEnabled` toggle, and * (b) a gender-derived default when that toggle is NULL. * - * `isCycleEnabled` is the single source of truth. The server gender enum - * ships UPPERCASE (`"MALE" | "FEMALE" | null`); the gate keys on - * `"FEMALE"`. A NULL toggle derives `gender === "FEMALE"`; an explicit + * `isCycleEnabled` is the per-user (LAYER 2) source of truth. The server + * gender enum ships UPPERCASE (`"MALE" | "FEMALE" | null`); the gate keys + * on `"FEMALE"`. A NULL toggle derives `gender === "FEMALE"`; an explicit * `true`/`false` overrides the derivation (so a non-FEMALE account can * opt in and a FEMALE account can opt out). * + * v1.18.0 — `isCycleEnabled` is the USER layer only and stays pure: the + * module foundation's `resolveModuleEnabled` AND-s it with the operator + * server-wide kill-switch (LAYER 1). Every SERVER-SIDE consumer that wants + * the fully-resolved decision (routes, the cycle-reminder cron, the coach + * cycle block, the import accumulator) must go through + * `isCycleAvailableForUser` / `requireCycleEnabled`, which delegate to + * `isModuleEnabled(userId, "cycle")` so an operator-off instance suppresses + * the whole vertical automatically. Clients read `user.modules.cycle` + * (already AND-ed) rather than the operator-unaware `cycleTrackingEnabled`. + * * NOTE: the gender enum normalisation (adding `OTHER` / lowercasing) is a * separate, iOS-blocked decision (migration 0131, reserved). Until that * lands, `OTHER` / non-binary accounts gate purely on the explicit @@ -18,11 +28,29 @@ */ import { apiError } from "@/lib/api-response"; import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; +import { isModuleEnabled } from "@/lib/modules/gate"; import type { CycleProfile } from "@/generated/prisma/client"; /** Wire `errorCode` the iOS retry classifier branches on. */ export const CYCLE_DISABLED_ERROR_CODE = "cycle.disabled"; +/** + * Fully-resolved cycle availability for an account: the per-user toggle + * (`isCycleEnabled`) AND-ed with the operator server-wide kill-switch + * (`AppSettings.moduleAvailabilityJson.cycle`). This delegates to the + * module foundation's `isModuleEnabled(userId, "cycle")`, which routes the + * `cycle` ModuleKey back through `isCycleEnabled` for the user layer — so + * there is exactly one source of truth and no duplicated operator logic. + * + * Every SERVER-SIDE cycle consumer (the cron, the coach cycle block, the + * import accumulator) should call this rather than the operator-unaware + * `isCycleEnabled`, so an operator-disabled instance suppresses the whole + * vertical automatically. + */ +export function isCycleAvailableForUser(userId: string): Promise { + return isModuleEnabled(userId, "cycle"); +} + /** * Resolve whether cycle tracking is enabled for an account. * @@ -51,16 +79,23 @@ export type CycleGateResult = /** * Enforce the gate for a `/api/cycle/*` route. Lazily upserts the - * `CycleProfile` (so the toggle row exists), resolves `isCycleEnabled`, - * and returns a 403 `cycle.disabled` envelope when the account may not - * write/read cycle data — even with a valid Bearer token. + * `CycleProfile` (so the toggle row exists), resolves the FULLY two-layer + * decision — the per-user toggle (`isCycleEnabled`) AND the operator + * server-wide kill-switch — and returns a 403 `cycle.disabled` envelope + * when the account may not write/read cycle data, even with a valid Bearer + * token. The `gender` argument stays in the signature for call-site + * symmetry; the resolved decision reads gender through the module gate. */ export async function requireCycleEnabled( userId: string, - gender: string | null | undefined, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _gender: string | null | undefined, ): Promise { - const profile = await getOrCreateCycleProfile(userId); - if (!isCycleEnabled(gender, profile)) { + const [profile, available] = await Promise.all([ + getOrCreateCycleProfile(userId), + isCycleAvailableForUser(userId), + ]); + if (!available) { return { enabled: false, response: apiError("Cycle tracking is not enabled", 403, { diff --git a/src/lib/cycle/import-accumulator.ts b/src/lib/cycle/import-accumulator.ts index ef3e9ea00..d1b1c1939 100644 --- a/src/lib/cycle/import-accumulator.ts +++ b/src/lib/cycle/import-accumulator.ts @@ -21,7 +21,7 @@ */ import { prisma } from "@/lib/db"; import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; -import { isCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { auditLog } from "@/lib/auth/audit"; import { mapHkCycleSample, @@ -154,25 +154,19 @@ export class CycleImportAccumulator { } /** - * Resolve whether cycle tracking is enabled for the user (gender-derived - * or the explicit toggle). Cached for the parse lifetime. The importer + * Resolve whether the cycle module is enabled for the user — the + * fully-resolved two-layer gate (per-user toggle AND the operator + * server-wide kill-switch). Cached for the parse lifetime. The importer * skips the whole cycle fold when this is false so a non-cycle account's - * Apple Health export never silently provisions cycle rows. + * (or an operator-disabled instance's) Apple Health export never silently + * provisions cycle rows. */ async isEnabled(): Promise { if (this.gated !== null) return this.gated; - const [user, profile] = await Promise.all([ - prisma.user.findUnique({ - where: { id: this.userId }, - select: { gender: true }, - }), - prisma.cycleProfile.findUnique({ - where: { userId: this.userId }, - select: { cycleTrackingEnabled: true }, - }), - ]); - // The single source-of-truth gate (no profile auto-create on import). - this.gated = isCycleEnabled(user?.gender, profile); + // v1.18.0 — the FULLY-resolved cycle module: the per-user toggle AND the + // operator server-wide kill-switch. An operator-disabled instance never + // accumulates / writes cycle rows from an import. No profile auto-create. + this.gated = await isCycleAvailableForUser(this.userId); return this.gated; } diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index d621e45a8..c1728d649 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -57,11 +57,42 @@ vi.mock("@/lib/analytics/health-score-fast-path", () => ({ computeUserHealthScoreFastPath: (...a: unknown[]) => computeUserHealthScoreFastPath(...a), })); +// v1.18.0 — module gate. Default-mocked all-on so the existing envelope +// tests keep their full tile set; the module-gating suite below passes +// an explicit map through the `modules` option instead. +const resolveModuleMap = vi.fn(); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: (...a: unknown[]) => resolveModuleMap(...a), +})); + +/** All eleven toggleable keys on, with optional per-key overrides. */ +function moduleMap( + overrides: Partial> = {}, +): Record { + const base: Record = { + cycle: true, + mood: true, + sleep: true, + glucose: true, + workouts: true, + recovery: true, + labs: true, + achievements: true, + coach: true, + insights: true, + doctorReport: true, + }; + return { ...base, ...overrides }; +} import { buildDashboardSnapshot, + WIDGET_MODULE_BY_ID, + SUMMARY_TYPE_MODULE, type SnapshotUserInput, } from "../snapshot"; +import type { ModuleKey } from "@/lib/modules/gate"; +import { MODULE_KEYS } from "@/lib/modules/registry"; const emptySummary = { count: 0, @@ -133,6 +164,7 @@ beforeEach(() => { nextDueMedicationName: null, }); computeUserHealthScoreFastPath.mockResolvedValue(null); + resolveModuleMap.mockResolvedValue(moduleMap()); }); describe("buildDashboardSnapshot — envelope shape", () => { @@ -766,3 +798,204 @@ describe("buildDashboardSnapshot — additive proof", () => { expect(snap.tiles).toHaveProperty("mood"); }); }); + +describe("module gating maps — every toggleable-domain widget is mapped (v1.18.0)", () => { + // The dashboard widget ids that belong to a toggleable module. A new + // toggleable widget MUST be added here AND to `WIDGET_MODULE_BY_ID`, or + // it would leak onto a disabled-module account's dashboard. + const TOGGLEABLE_WIDGET_IDS = [ + "mood", + "sleep", + "glucose", + "achievements", + "recentWorkouts", + "cardioRecovery", + "sixMinuteWalk", + "stairAscentSpeed", + "stairDescentSpeed", + "breathingDisturbances", + ] as const; + + it("every toggleable-domain widget id has a module mapping", () => { + for (const id of TOGGLEABLE_WIDGET_IDS) { + expect( + WIDGET_MODULE_BY_ID[id], + `widget "${id}" is missing a WIDGET_MODULE_BY_ID entry`, + ).toBeDefined(); + } + }); + + it("every mapped widget value is a real ModuleKey", () => { + for (const [id, key] of Object.entries(WIDGET_MODULE_BY_ID)) { + expect( + MODULE_KEYS.includes(key as ModuleKey), + `widget "${id}" maps to unknown module "${key}"`, + ).toBe(true); + } + }); + + it("every summary-type mapping value is a real ModuleKey", () => { + for (const [type, key] of Object.entries(SUMMARY_TYPE_MODULE)) { + expect( + MODULE_KEYS.includes(key as ModuleKey), + `summary type "${type}" maps to unknown module "${key}"`, + ).toBe(true); + } + }); + + it("recovery-domain widgets resolve to the recovery module", () => { + expect(WIDGET_MODULE_BY_ID.cardioRecovery).toBe("recovery"); + expect(WIDGET_MODULE_BY_ID.sixMinuteWalk).toBe("recovery"); + expect(WIDGET_MODULE_BY_ID.stairAscentSpeed).toBe("recovery"); + expect(WIDGET_MODULE_BY_ID.stairDescentSpeed).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.CARDIO_RECOVERY).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.SIX_MINUTE_WALK_DISTANCE).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.STAIR_ASCENT_SPEED).toBe("recovery"); + expect(SUMMARY_TYPE_MODULE.STAIR_DESCENT_SPEED).toBe("recovery"); + }); + + it("breathing-disturbance widget + type resolve to the sleep module", () => { + expect(WIDGET_MODULE_BY_ID.breathingDisturbances).toBe("sleep"); + expect(SUMMARY_TYPE_MODULE.BREATHING_DISTURBANCES).toBe("sleep"); + }); +}); + +describe("buildDashboardSnapshot — module gating (v1.18.0)", () => { + const widget = (snap: { layout: { widgets: { id: string; visible: boolean; tileVisible?: boolean }[] } }, id: string) => + snap.layout.widgets.find((w) => w.id === id); + const cat = (snap: { layoutCatalogue: { id: string; visible: boolean }[] }, id: string) => + snap.layoutCatalogue.find((w) => w.id === id); + + beforeEach(() => { + // A warm, data-rich account so every toggleable surface would + // otherwise render — the assertions then prove the disabled ones drop. + probeRollupCoverage.mockResolvedValue( + new Map([ + ["WEIGHT", true], + ["BLOOD_PRESSURE_SYS", true], + ["BLOOD_PRESSURE_DIA", true], + ]), + ); + computeBpInTargetFastPath.mockResolvedValue({ + last7Days: { pct: 70 }, + last30Days: { pct: 80 }, + last90Days: { pct: 80, pairs: 20 }, + last90EarliestAt: new Date("2026-03-01T00:00:00.000Z"), + allTime: { pct: 75 }, + priorMonth: { pct: 60 }, + priorYear: { pct: 50 }, + }); + computeSummariesSlice.mockResolvedValue({ + summaries: { + WEIGHT: { ...emptySummary, count: 3, latest: 80 }, + SLEEP_DURATION: { ...emptySummary, count: 5, latest: 420 }, + BLOOD_GLUCOSE: { ...emptySummary, count: 9, latest: 95 }, + }, + lastSeenByType: { + WEIGHT: { lastSeenAt: new Date().toISOString() }, + SLEEP_DURATION: { lastSeenAt: new Date().toISOString() }, + BLOOD_GLUCOSE: { lastSeenAt: new Date().toISOString() }, + }, + bmi: null, + }); + // Glucose rows so `extras.glucoseByContext` + `glucoseClinical` populate. + fakePrisma.measurement.findMany.mockResolvedValue([ + { value: 95, measuredAt: new Date(), glucoseContext: "FASTING" }, + { value: 110, measuredAt: new Date(), glucoseContext: "FASTING" }, + ]); + // Mood entries so the tile would otherwise carry a summary. + fakePrisma.moodEntry.findMany.mockResolvedValue([ + { date: new Date(), score: 4 }, + ]); + }); + + it("all modules on: every toggleable tile + its data is present", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap()), + }); + expect(widget(snap, "mood")!.visible).toBe(true); + expect(widget(snap, "sleep")).toBeDefined(); + expect(widget(snap, "glucose")).toBeDefined(); + expect(widget(snap, "achievements")!.visible).toBe(true); + expect(widget(snap, "recentWorkouts")!.visible).toBe(true); + expect(snap.tiles.summaries.SLEEP_DURATION).toBeDefined(); + expect(snap.tiles.summaries.BLOOD_GLUCOSE).toBeDefined(); + expect(snap.tiles.mood.entries.length).toBeGreaterThan(0); + expect(Object.keys(snap.extras!.glucoseByContext).length).toBeGreaterThan(0); + }); + + it("mood disabled: tile hidden + mood block emptied", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ mood: false })), + }); + expect(widget(snap, "mood")!.visible).toBe(false); + expect(widget(snap, "mood")!.tileVisible).toBe(false); + expect(cat(snap, "mood")!.visible).toBe(false); + expect(snap.tiles.mood.entries).toEqual([]); + expect(snap.tiles.mood.summary).toBeNull(); + // Core tile unaffected. + expect(widget(snap, "weight")!.visible).toBe(true); + }); + + it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ sleep: false })), + }); + expect(widget(snap, "sleep")!.visible).toBe(false); + expect(snap.tiles.summaries.SLEEP_DURATION).toBeUndefined(); + expect(snap.tiles.lastSeenByType.SLEEP_DURATION).toBeUndefined(); + expect(snap.metricStates.sleep).toBeUndefined(); + // Glucose untouched. + expect(snap.tiles.summaries.BLOOD_GLUCOSE).toBeDefined(); + }); + + it("glucose disabled: tile hidden + summary stripped + clinical panel cleared", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ glucose: false })), + }); + expect(widget(snap, "glucose")!.visible).toBe(false); + expect(snap.tiles.summaries.BLOOD_GLUCOSE).toBeUndefined(); + expect(snap.metricStates.bloodGlucose).toBeUndefined(); + expect(snap.extras!.glucoseByContext).toEqual({}); + // Sleep untouched. + expect(snap.tiles.summaries.SLEEP_DURATION).toBeDefined(); + }); + + it("workouts disabled: recent-workouts tile hidden", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ workouts: false })), + }); + expect(widget(snap, "recentWorkouts")!.visible).toBe(false); + expect(widget(snap, "recentWorkouts")!.tileVisible).toBe(false); + expect(cat(snap, "recentWorkouts")!.visible).toBe(false); + }); + + it("achievements disabled: recent-unlocks tile hidden", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), { + modules: () => Promise.resolve(moduleMap({ achievements: false })), + }); + expect(widget(snap, "achievements")!.visible).toBe(false); + }); + + it("insights disabled: Daily Briefing surface goes to disabled state", async () => { + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedText: JSON.stringify({ + dailyBriefing: { headline: "x", body: "y", generatedAt: new Date().toISOString() }, + }), + insightsCachedAt: new Date(), + }), + { modules: () => Promise.resolve(moduleMap({ insights: false })) }, + ); + expect(snap.briefingState).toBe("disabled"); + expect(snap.briefing).toBeNull(); + }); + + it("defaults to resolveModuleMap when no override is injected", async () => { + resolveModuleMap.mockResolvedValue(moduleMap({ mood: false })); + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + expect(resolveModuleMap).toHaveBeenCalledWith("user-1"); + expect(widget(snap, "mood")!.visible).toBe(false); + }); +}); diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index ebf86b95d..2fc0c682c 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -78,10 +78,27 @@ import { } from "@/lib/dashboard/meds-today"; import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path"; import type { HealthScoreBand } from "@/lib/analytics/health-score"; +import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ const BRIEFING_TTL_MS = 24 * 60 * 60 * 1000; +/** + * v1.18.0 — the two dashboard module maps live in a client-safe module + * (`@/lib/dashboard/widget-modules`) so the settings client component can + * import them without pulling the server snapshot builder (and its + * `pg` / `dns` chain) into the browser bundle. Re-exported here for the + * server call sites and the existing tests. + */ +export { + WIDGET_MODULE_BY_ID, + SUMMARY_TYPE_MODULE, +} from "@/lib/dashboard/widget-modules"; +import { + WIDGET_MODULE_BY_ID, + SUMMARY_TYPE_MODULE, +} from "@/lib/dashboard/widget-modules"; + const GLUCOSE_CONTEXTS = [ "FASTING", "POSTPRANDIAL", @@ -745,6 +762,59 @@ function isThickPhaseWarm(coverage: RollupCoverageMap): boolean { ); } +/** + * v1.18.0 — force every widget whose module is disabled to invisible on + * the resolved layout (both `visible` and `tileVisible`). Order is + * preserved so a re-enable restores the user's saved position. Pure + * projection — the persisted `dashboardWidgetsJson` is untouched; only + * what the snapshot publishes is gated. + */ +function gateLayoutByModules( + layout: DashboardLayout, + modules: Record, +): DashboardLayout { + return { + ...layout, + widgets: layout.widgets.map((w) => { + const moduleKey = WIDGET_MODULE_BY_ID[w.id]; + if (moduleKey && modules[moduleKey] === false) { + return { ...w, visible: false, tileVisible: false }; + } + return w; + }), + }; +} + +/** + * v1.18.0 — strip disabled-module measurement types from the slim slice + * so neither `tiles.summaries` / `tiles.lastSeenByType` nor the derived + * `metricStates` carry data for a module the user turned off. Returns a + * shallow copy; the inputs are not mutated. + */ +function gateSummariesByModules( + summaries: Record, + lastSeenByType: Record, + modules: Record, +): { + summaries: Record; + lastSeenByType: Record; +} { + const dropped = new Set(); + for (const [type, moduleKey] of Object.entries(SUMMARY_TYPE_MODULE)) { + if (moduleKey && modules[moduleKey] === false) dropped.add(type); + } + if (dropped.size === 0) return { summaries, lastSeenByType }; + const outSummaries: Record = {}; + for (const [type, summary] of Object.entries(summaries)) { + if (!dropped.has(type)) outSummaries[type] = summary; + } + const outLastSeen: Record = {}; + for (const [type, slot] of Object.entries(lastSeenByType)) { + if (!dropped.has(type)) outLastSeen[type] = slot; + } + return { summaries: outSummaries, lastSeenByType: outLastSeen }; +} + /** * Assemble the full snapshot in ONE `Promise.all`. Every sub-read is * timed via the optional `time` wrapper so a regression is attributable @@ -762,6 +832,14 @@ export async function buildDashboardSnapshot( * `@/lib/ai/provider` (no decrypt, no network). */ hasProvider?: () => Promise; + /** + * v1.18.0 — resolved per-user module map. Injectable for tests; + * defaults to `resolveModuleMap(user.id)` (memoised per request). + * Disabled toggleable modules have their dashboard tiles / data + * stripped from the snapshot at the build layer so nothing leaks to + * the client. + */ + modules?: () => Promise>; } = {}, ): Promise { const userTz = user.timezone ?? DEFAULT_TIMEZONE; @@ -780,25 +858,64 @@ export async function buildDashboardSnapshot( const coverage = await time("coverage", () => probeRollupCoverage(user.id)); const warm = isThickPhaseWarm(coverage); - const [slim, mood, extrasResult, flags, medsToday] = await Promise.all([ - // A5 — reuse the coverage map already probed above so the slice - // doesn't re-run the identical `probeRollupCoverage` query. - time("summaries", () => computeSummariesSlice(user.id, coverage)), - time("mood", () => buildMoodBlock(prisma, user.id)), - warm - ? time("extras", () => - buildExtras(prisma, user, userTz, coverage, now, time), - ) - : Promise.resolve(null), - time("flags", () => getAssistantFlags()), - // Fast phase — projection-backed today tally + earliest next-due. - time("medsToday", () => buildMedsTodayBlock(prisma, user.id, userTz, now)), - ]); + const [slimRaw, moodRaw, extrasResult, flags, medsToday, modules] = + await Promise.all([ + // A5 — reuse the coverage map already probed above so the slice + // doesn't re-run the identical `probeRollupCoverage` query. + time("summaries", () => computeSummariesSlice(user.id, coverage)), + time("mood", () => buildMoodBlock(prisma, user.id)), + warm + ? time("extras", () => + buildExtras(prisma, user, userTz, coverage, now, time), + ) + : Promise.resolve(null), + time("flags", () => getAssistantFlags()), + // Fast phase — projection-backed today tally + earliest next-due. + time("medsToday", () => + buildMedsTodayBlock(prisma, user.id, userTz, now), + ), + // v1.18.0 — resolved module map (memoised per request); gates the + // toggleable tiles below at the build layer. + time("modules", () => + (options.modules ?? (() => resolveModuleMap(user.id)))(), + ), + ]); - const layout = resolveDashboardLayout(user.dashboardWidgetsJson); + // v1.18.0 — strip disabled-module data before it leaves the server. + // Mood is blanked when the mood module is off; sleep / glucose summary + // types are dropped from the slim slice; the glucose clinical panel + + // per-context block are cleared when glucose is off; the layout + + // catalogue hide every disabled-module widget. + const slim = gateSummariesByModules( + slimRaw.summaries, + slimRaw.lastSeenByType, + modules, + ); + const mood = + modules.mood === false + ? { summary: null, entries: [] as DashboardSnapshotMoodEntry[] } + : moodRaw; + if (extrasResult && modules.glucose === false) { + extrasResult.extras.glucoseByContext = {}; + extrasResult.extras.glucoseClinical = computeGlucoseClinicalMetrics([], { + windowDays: GLUCOSE_PANEL_WINDOW_DAYS, + now, + }); + } + + const layout = gateLayoutByModules( + resolveDashboardLayout(user.dashboardWidgetsJson), + modules, + ); + // v1.18.0 — the Daily Briefing is the dashboard's AI-narrative surface, + // so the `insights` module gates it alongside the operator briefing + // flag + per-user coach opt-out. Disabling `insights` yields the same + // `briefingState: "disabled"` empty surface the existing flags produce + // (the raw weight/BP/pulse data stays untouched — `insights` is the + // narrative layer, not the data layer). const briefing = await liftBriefing( user, - flags.briefing, + flags.briefing && modules.insights !== false, options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)), ); diff --git a/src/lib/dashboard/widget-modules.ts b/src/lib/dashboard/widget-modules.ts new file mode 100644 index 000000000..80af8e63b --- /dev/null +++ b/src/lib/dashboard/widget-modules.ts @@ -0,0 +1,61 @@ +/** + * v1.18.0 — client-safe module maps for dashboard widgets / summary types. + * + * These two maps are pure data (string → ModuleKey) with no runtime + * dependency on the DB, Prisma, or the server-only module gate. They are + * factored out of `@/lib/dashboard/snapshot` so the settings client + * component (`dashboard-layout-section.tsx`) can import them without + * dragging the whole server snapshot builder — and its transitive + * `pg` / `dns` chain — into the browser bundle. `snapshot.ts` re-exports + * both for the server call sites and the existing tests. + * + * `ModuleKey` comes from `@/lib/modules/registry` (pure constants, no + * imports) and `MeasurementType` is a type-only import from the generated + * Prisma client, so this file stays browser-bundle-safe. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { ModuleKey } from "@/lib/modules/registry"; + +/** + * Dashboard widget id → toggleable module key. When the user disables the + * module, the matching widget is forced invisible on both the web `layout` + * and the iOS `layoutCatalogue`, so the tile/chart never paints. Only the + * toggleable surfaces appear here; CORE widgets (weight / bp / pulse / + * bodyFat / medications / bpInTarget and the vital-derived HealthKit + * metrics) carry NO entry and are never hidden. + */ +export const WIDGET_MODULE_BY_ID: Partial> = { + mood: "mood", + sleep: "sleep", + glucose: "glucose", + achievements: "achievements", + recentWorkouts: "workouts", + // v1.18.0 B1 — recovery-domain HealthKit widgets belong to the recovery + // module; the per-night breathing-disturbance widget belongs to sleep. + cardioRecovery: "recovery", + sixMinuteWalk: "recovery", + stairAscentSpeed: "recovery", + stairDescentSpeed: "recovery", + breathingDisturbances: "sleep", +}; + +/** + * `MeasurementType` slim-summary keys that belong to a toggleable module. + * When the module is off the key is stripped from `tiles.summaries` / + * `tiles.lastSeenByType` (so `metricStates` and the client data-floor + * gates also drop it) before the snapshot leaves the server. Core vital + * types are absent here and always pass through. + */ +export const SUMMARY_TYPE_MODULE: Partial> = { + SLEEP_DURATION: "sleep", + BLOOD_GLUCOSE: "glucose", + // v1.18.0 B1 — recovery-domain HealthKit metrics. The recovery page + + // its tiles are the recovery module's surface; when it is off these + // device-native signals must drop from the dashboard snapshot too. + CARDIO_RECOVERY: "recovery", + SIX_MINUTE_WALK_DISTANCE: "recovery", + STAIR_ASCENT_SPEED: "recovery", + STAIR_DESCENT_SPEED: "recovery", + // Per-night breathing-disturbance index is a sleep-page signal. + BREATHING_DISTURBANCES: "sleep", +}; diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index f08da5c08..c909bdac2 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -25,7 +25,11 @@ import { import type { GlucoseContext, MeasurementSource, + MeasurementType, } from "@/generated/prisma/client"; +import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; +import { userDayKey } from "@/lib/tz/resolver"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; import { reconstructSleepNights, @@ -36,6 +40,7 @@ import { type DoctorReportPrefs, } from "@/lib/validations/doctor-report-prefs"; import { buildCycleExportSummary } from "@/lib/cycle/export-data"; +import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { buildComplianceMedicationContext, lastNonSkippedTakenAt, @@ -60,6 +65,29 @@ export interface DoctorReportCompliance { missed: number; } +/** + * Canonical adherence-rate rounding for the doctor-report export surfaces. + * + * The PDF table, the PDF clinical-summary headline, the FHIR adherence + * Observation, and the GLP-1 block all derive the rate from the SAME ledger + * denominator (`taken / total`, `total = taken + missed`). They MUST round it + * the same way the app does so a clinician comparing the export to the in-app + * card never sees a presentational divergence (87 % on the card, 87.3 % on the + * PDF). The app convention is `round(100 · taken / denominator)` capped at 100 + * (see `dose-history-ledger-compute.ts` + `tallyComplianceFromLedger`) — an + * integer percent. This is the one source of truth for every export surface. + * + * Returns `null` when `total <= 0` so callers render the "no expected dose" + * placeholder instead of a misleading `0` or `100`. + */ +export function adherenceRatePercent( + taken: number, + total: number, +): number | null { + if (total <= 0) return null; + return Math.min(100, Math.round((taken / total) * 100)); +} + export interface DoctorReportMood { avg: number; min: number; @@ -287,6 +315,70 @@ interface RecoveryMeasurementRow { source: MeasurementSource; } +/** Minimal Measurement shape the canonical-source collapse consults. */ +export interface CanonicalCollapseRow { + type: string; + value: number; + measuredAt: Date; + source: MeasurementSource; + deviceType?: string | null; +} + +/** + * Collapse a window's raw Measurement rows to one canonical source per metric + * before the doctor report computes per-type avg/min/max. Without this the PDF + * + FHIR stats blend every source (a WHOOP + Apple-Watch resting-heart-rate + * day would average two readings), while the dashboard / insights aggregator + * already collapse to the ladder-canonical source per day. This routes the rows + * through the SAME picker (`pickCanonicalSourceRows`, keyed by + * `metricKeyForType` + the user's day key + source-priority ladder), so the two + * surfaces report identical numbers. + * + * SLEEP_DURATION is passed through untouched: it carries per-stage rows that + * `reconstructSleepNights` resolves with its own source de-dup downstream, so a + * pre-collapse here would drop stages the night reconstruction needs. A type + * with no ladder, or a day with a single source, passes through unchanged via + * the picker's documented fallback. Exported for unit tests. + */ +export function collapseMeasurementsToCanonical( + measurements: readonly T[], + timezone: string, + sourcePriorityJson: unknown, +): T[] { + const dayKey = (d: Date) => userDayKey(d, timezone); + const byType = new Map(); + for (const m of measurements) { + const slot = byType.get(m.type); + if (slot) slot.push(m); + else byType.set(m.type, [m]); + } + const out: T[] = []; + for (const [type, rows] of byType) { + const metricKey = + type === "SLEEP_DURATION" + ? null + : metricKeyForType(type as MeasurementType); + if (!metricKey) { + out.push(...rows); + continue; + } + const { canonicalRows } = pickCanonicalSourceRows( + rows.map((m) => ({ + ...m, + type: m.type as MeasurementType, + })), + metricKey, + sourcePriorityJson, + dayKey, + ); + // `pickCanonicalSourceRows` returns the spread copies; re-key back to the + // original rows by reference identity is unnecessary because the spread + // preserves every field the caller reads (value / measuredAt / type). + out.push(...(canonicalRows as unknown as T[])); + } + return out; +} + /** * Summarise RECOVERY_SCORE over the window from the CANONICAL row per day: * a WHOOP-native row wins over the COMPUTED proxy for the same day, so the @@ -483,6 +575,18 @@ export interface CollectDoctorReportOptions { * don't accidentally print a section the user opted out of. */ sections?: Partial; + /** + * v1.18.0 — resolved per-user module enable/disable map. When omitted the + * aggregator resolves it itself via `resolveModuleMap(userId)`. A disabled + * module forces its report section / FHIR resources out of the payload, + * regardless of the user's per-report `sections` toggles: a user who turned + * off the Sleep module exports no sleep series, mood Observations, glucose + * panel, recovery/strain scores, cycle summary, or lab results for the + * modules they no longer keep. Core clinical sections (weight / BP / pulse / + * medications) carry no module key and are always included. Injectable for + * tests so the module ↔ section mapping can be asserted without a DB. + */ + moduleMap?: Record; } /** @@ -602,10 +706,33 @@ export async function collectDoctorReportData( // either way (the queries are cheap on indexed columns) and stripped // from the returned payload below — keeping a single source of // truth for the data shape regardless of toggle state. + // v1.18.0 — resolve the per-user module map up-front (once per build). A + // disabled module's section/resources are excluded from the export so the + // PDF + FHIR bundle reflect only the modules the user keeps. The map is + // ANDed over the user's per-report `sections` toggles: a disabled module + // forces its section off even if the report toggle says on (you can't share + // data for a module you turned off), while leaving the others untouched. + // Core clinical sections (weight / BP / pulse / medications) have no module + // key and stay unconditional. Injectable for tests. + const moduleMap = + options.moduleMap ?? (await resolveModuleMap(userId)); + const sections: DoctorReportPrefs = { ...DEFAULT_DOCTOR_REPORT_PREFS, ...(options.sections ?? {}), }; + // Module gate: a disabled module wins over the report toggle. Each report + // section maps to its owning module key; core sections (bp/weight/pulse/ + // bmi/compliance) carry no key and are never gated here. + if (moduleMap.mood === false) sections.mood = false; + if (moduleMap.sleep === false) sections.sleep = false; + if (moduleMap.cycle === false) sections.cycle = false; + if (moduleMap.labs === false) sections.labs = false; + // Module gates with no `sections` key — applied directly to the data slices + // below (glucose panel, recovery/strain wellness scores, workout series). + const glucoseEnabled = moduleMap.glucose !== false; + const recoveryEnabled = moduleMap.recovery !== false; + const workoutsEnabled = moduleMap.workouts !== false; const [measurements, medications, intakeEvents, moodEntries, userProfile] = await Promise.all([ @@ -703,18 +830,35 @@ export async function collectDoctorReportData( }), ]); - // Group measurements by type. + const reportTz = userProfile?.timezone ?? "Europe/Berlin"; + + // v1.18.0 — collapse each multi-source metric to its CANONICAL source before + // grouping, so the report's per-type avg/min/max match the dashboard rather + // than blending overlapping sources (e.g. WHOOP + Apple Watch resting-heart- + // rate summed into one inflated mean). See `collapseMeasurementsToCanonical`. + const canonicalMeasurements = collapseMeasurementsToCanonical( + measurements, + reportTz, + userProfile?.sourcePriorityJson ?? null, + ); + + // Group measurements by type (canonical-source-collapsed). const byType: Record< string, Array<{ value: number; measuredAt: string }> > = {}; - for (const m of measurements) { + for (const m of canonicalMeasurements) { if (!byType[m.type]) byType[m.type] = []; byType[m.type].push({ value: m.value, measuredAt: m.measuredAt.toISOString(), }); } + // measuredAt-ascending within each type so `latest` (last element) is the + // most recent reading after the canonical collapse reorders by bucket. + for (const entries of Object.values(byType)) { + entries.sort((a, b) => a.measuredAt.localeCompare(b.measuredAt)); + } // v1.17.1 parity — SLEEP_DURATION enters `byType` as RAW per-stage rows (a // 40-min DEEP block, a 12-min AWAKE block, …). Every other sleep surface @@ -723,7 +867,6 @@ export async function collectDoctorReportData( // and the v1.17.1 stamping fixes rewrite exactly those raw stage rows. Route // the report's sleep value through the same engine — one number, one surface // — exactly as RECOVERY_SCORE routes through `summariseCanonicalRecovery`. - const reportTz = userProfile?.timezone ?? "Europe/Berlin"; const sleepRows = measurements.filter( (m) => m.type === "SLEEP_DURATION", ) as unknown as SleepStageRow[]; @@ -916,7 +1059,12 @@ export async function collectDoctorReportData( // Per-context glucose stats + effective ranges (canonical mg/dL). const glucoseStats: Record = {}; const glucoseRanges: Record = {}; - const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE"); + // v1.18.0 — when the glucose module is disabled, no glucose row enters the + // panel: stats, ranges, and the clinical metrics all collapse to empty so the + // PDF + FHIR Observations carry no glucose for this user. + const glucoseRows = glucoseEnabled + ? measurements.filter((m) => m.type === "BLOOD_GLUCOSE") + : []; const overrides = (userProfile?.thresholdsJson ?? null) as ThresholdOverridesJson | null; const profileForRange = { @@ -966,8 +1114,8 @@ export async function collectDoctorReportData( // `stats`, and `compliance` lets the PDF renderer treat "section // disabled" identically to "section had no rows" — both paths skip // the table. Mood is already null when disabled (we never queried). - const filteredByType = filterMeasurementKeys(byType, sections); - const filteredStats = filterMeasurementKeys(stats, sections); + const filteredByType = filterMeasurementKeys(byType, sections, moduleMap); + const filteredStats = filterMeasurementKeys(stats, sections, moduleMap); const filteredCompliance = sections.compliance ? compliance : {}; const filteredBmi = sections.bmi ? bmi : null; @@ -1085,6 +1233,19 @@ export async function collectDoctorReportData( // series. Resolve to the canonical row per day (WHOOP wins) so the PDF reads // the SAME value the tile + iOS feed show — one number, one engine. const wellnessScoreSummaries = WELLNESS_SCORE_REPORT_TYPES.flatMap((type) => { + // v1.18.0 — module gate per score type. Recovery + stress are the + // recovery/readiness signals (recovery module); strain is the + // training-load signal derived from the workouts table (workouts module). + // A disabled owning module drops the score from the wellness summary and, + // since the FHIR exporter sources these Observations from + // `data.wellnessScores`, from the bundle too. + if (type === "STRAIN_SCORE" && !workoutsEnabled) return []; + if ( + (type === "RECOVERY_SCORE" || type === "STRESS_SCORE") && + !recoveryEnabled + ) { + return []; + } if (type === "RECOVERY_SCORE") { const summary = summariseCanonicalRecovery(measurements, reportTz); return summary ? [summary] : []; @@ -1234,14 +1395,52 @@ const MEASUREMENT_TYPE_SECTION: Record = { SLEEP_DURATION: "sleep", }; +/** + * v1.18.0 — per-measurement-type → owning module mapping for the types that + * carry no `sections` toggle but DO belong to a toggleable module. When the + * module is disabled, the type is stripped from `measurements` + `stats` so the + * series never reaches the PDF tables or a FHIR Observation. + * + * - `workouts` owns the activity / movement series (steps, active energy, + * distance, flights, the walking-gait + stair metrics) and the + * training-load `STRAIN_SCORE`. + * - `recovery` owns the readiness signals `RECOVERY_SCORE` + `STRESS_SCORE`. + * - `glucose` owns the raw `BLOOD_GLUCOSE` series (the glucose panel itself is + * gated where it is assembled; this strips the raw rows too). + * + * Core clinical types (BP / weight / pulse / body-composition / vitals) carry + * no module and are never gated here. `sleep` / `mood` / `cycle` / `labs` ride + * the `sections` mechanism instead. + */ +const MEASUREMENT_TYPE_MODULE: Record = { + ACTIVITY_STEPS: "workouts", + ACTIVE_ENERGY_BURNED: "workouts", + FLIGHTS_CLIMBED: "workouts", + WALKING_RUNNING_DISTANCE: "workouts", + WALKING_SPEED: "workouts", + WALKING_ASYMMETRY: "workouts", + WALKING_STEP_LENGTH: "workouts", + WALKING_DOUBLE_SUPPORT: "workouts", + SIX_MINUTE_WALK_DISTANCE: "workouts", + STAIR_ASCENT_SPEED: "workouts", + STAIR_DESCENT_SPEED: "workouts", + STRAIN_SCORE: "workouts", + RECOVERY_SCORE: "recovery", + STRESS_SCORE: "recovery", + BLOOD_GLUCOSE: "glucose", +}; + function filterMeasurementKeys( map: Record, sections: DoctorReportPrefs, + moduleMap: Record, ): Record { const out: Record = {}; for (const [type, value] of Object.entries(map)) { const sectionKey = MEASUREMENT_TYPE_SECTION[type]; if (sectionKey && sections[sectionKey] === false) continue; + const moduleKey = MEASUREMENT_TYPE_MODULE[type]; + if (moduleKey && moduleMap[moduleKey] === false) continue; out[type] = value; } return out; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 36ea49480..b3e623b8b 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -14,7 +14,14 @@ import autoTable from "jspdf-autotable"; import { makeFormatters } from "./format-locale"; import type { Locale } from "./i18n/config"; import { convertGlucose, resolveGlucoseUnit } from "./glucose"; -import type { DoctorReportData } from "./doctor-report-data"; +import { + classifyReferenceRange, + formatReferenceRange, +} from "./labs/reference-range"; +import { + adherenceRatePercent, + type DoctorReportData, +} from "./doctor-report-data"; type T = (key: string, params?: Record) => string; @@ -329,10 +336,11 @@ function buildClinicalSummaryLines( const compEntries = Object.values(data.compliance); const totalDoses = compEntries.reduce((s, c) => s + c.total, 0); const totalTaken = compEntries.reduce((s, c) => s + c.taken, 0); - if (totalDoses > 0) { + const headlineRate = adherenceRatePercent(totalTaken, totalDoses); + if (headlineRate !== null) { lines.push( t("doctorReport.summaryAdherence", { - rate: num((totalTaken / totalDoses) * 100, 1), + rate: num(headlineRate, 0), }), ); } @@ -742,6 +750,108 @@ export function buildDoctorReportPdfDocument( .finalY + 8; } + // v1.18.0 — clinical glucose panel (TIR / GMI / eA1C / mean / CV%). The + // server already computes this (`data.glucoseClinical`) by the one + // literature-locked engine the insights panel + coach consume, and zeroes it + // when the glucose module is off (readingCount === 0). Render it whenever the + // window carries glucose readings so the PDF surfaces the same numbers the + // app shows. A `pct` helper turns the engine's 0–1 fractions into percent. + const clinical = data.glucoseClinical; + if (clinical && clinical.readingCount > 0 && clinical.distribution) { + const pct = (fraction: number) => `${num(fraction * 100)} %`; + const clinicalRows: string[][] = []; + const dist = clinical.distribution; + clinicalRows.push([t("doctorReport.glucoseClinical.tirInRange"), pct(dist.tir)]); + clinicalRows.push([t("doctorReport.glucoseClinical.tirLow"), pct(dist.tbrLevel1)]); + clinicalRows.push([t("doctorReport.glucoseClinical.tirVeryLow"), pct(dist.tbrLevel2)]); + clinicalRows.push([t("doctorReport.glucoseClinical.tirHigh"), pct(dist.tarLevel1)]); + clinicalRows.push([ + t("doctorReport.glucoseClinical.tirVeryHigh"), + pct(dist.tarLevel2), + ]); + if (clinical.meanMgdl !== null) { + clinicalRows.push([ + t("doctorReport.glucoseClinical.mean"), + `${num(convertGlucose(clinical.meanMgdl, glucoseUnit))} ${glucoseUnit}`.trim(), + ]); + } + if (clinical.gmi !== null) { + clinicalRows.push([t("doctorReport.glucoseClinical.gmi"), `${num(clinical.gmi)} %`]); + } + if (clinical.estimatedA1c !== null) { + clinicalRows.push([ + t("doctorReport.glucoseClinical.eA1c"), + `${num(clinical.estimatedA1c)} %`, + ]); + } + if (clinical.variability !== null) { + clinicalRows.push([ + t("doctorReport.glucoseClinical.cv"), + `${num(clinical.variability.cv)} %`, + ]); + } + + // Keep the heading with the start of its table. + y = ensureSpace(y, 6 + 14); + doc.setFontSize(12); + doc.setFont("helvetica", "bold"); + doc.setTextColor(30, 30, 30); + doc.text(t("doctorReport.glucoseClinical.title"), margin, y); + y += 6; + + autoTable(doc, { + startY: y, + head: [ + [ + t("doctorReport.glucoseClinical.colMetric"), + t("doctorReport.glucoseClinical.colValue"), + ], + ], + body: clinicalRows, + theme: "grid", + styles: { + fontSize: 9, + cellPadding: 3, + textColor: [30, 30, 30], + lineColor: [200, 200, 200], + lineWidth: 0.3, + }, + headStyles: { + fillColor: [245, 245, 245], + textColor: [30, 30, 30], + fontStyle: "bold", + }, + alternateRowStyles: { fillColor: [252, 252, 252] }, + margin: { + left: margin, + right: margin, + top: margin, + bottom: tableBottomMargin, + }, + }); + y = + (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable + .finalY + 4; + + // Spot-reading caveat when the engine flags the readings as sparse, so the + // clinician reads the panel as a direction-of-travel, not a CGM AGP. + if (clinical.isSpotEstimate) { + doc.setFontSize(8); + doc.setFont("helvetica", "italic"); + doc.setTextColor(110, 110, 110); + const caveat = doc.splitTextToSize( + t("doctorReport.glucoseClinical.spotCaveat"), + pageWidth - 2 * margin, + ); + for (const line of caveat) { + y = ensureSpace(y, 4); + doc.text(line, margin, y); + y += 4; + } + } + y += 4; + } + // v1.7.0 — jsPDF-native trend sparklines per primary vital. Vector // polylines drawn with `doc.lines()` — zero new dependency, no native // canvas module, isomorphic. Selection-gated via `includeCharts`. @@ -880,7 +990,9 @@ export function buildDoctorReportPdfDocument( // detail-page adherence %. The column is labelled "Expected" rather // than "Total" so the row stays internally coherent: Taken + Missed = // Expected, with Skipped shown alongside as informational. - const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—"; + // Integer percent via the canonical rounding — matches the in-app card. + const ratePct = adherenceRatePercent(c.taken, c.total); + const rate = ratePct !== null ? `${num(ratePct, 0)}%` : "—"; return [ name, String(c.taken), @@ -984,13 +1096,16 @@ export function buildDoctorReportPdfDocument( ); y += 5; } - if (med.compliance.total > 0) { - const rate = (med.compliance.taken / med.compliance.total) * 100; + const glp1Rate = adherenceRatePercent( + med.compliance.taken, + med.compliance.total, + ); + if (glp1Rate !== null) { doc.text( t("doctorReport.glp1Compliance", { taken: med.compliance.taken, total: med.compliance.total, - rate: num(rate, 1), + rate: num(glp1Rate, 0), }), margin, y, @@ -1338,15 +1453,8 @@ export function buildDoctorReportPdfDocument( doc.text(t("doctorReport.labsTitle"), margin, y); y += 6; - const rangeText = ( - low: number | null, - high: number | null, - ): string => { - if (low !== null && high !== null) return `${num(low)}–${num(high)}`; - if (high !== null) return `≤ ${num(high)}`; - if (low !== null) return `≥ ${num(low)}`; - return "—"; - }; + const rangeText = (low: number | null, high: number | null): string => + formatReferenceRange(low, high, num, { emptyText: "—" }); // Quiet, non-colour status glyph. In-range reads as a neutral dash so // the table is not a field of warning marks; out-of-range reads as a // direction arrow the clinician can scan, with no alarm tint. @@ -1355,10 +1463,16 @@ export function buildDoctorReportPdfDocument( low: number | null, high: number | null, ): string => { - if (low === null && high === null) return ""; - if (low !== null && value < low) return "↓"; - if (high !== null && value > high) return "↑"; - return "—"; + switch (classifyReferenceRange(value, low, high)) { + case "unknown": + return ""; + case "below": + return "↓"; + case "above": + return "↑"; + default: + return "—"; + } }; const labRows = data.labResults.map((lr) => [ diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index e2944db1f..b0463690d 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -296,7 +296,8 @@ describe("Coach disable cascade invariant", () => { "src/components/insights/suggested-prompts.tsx", // v1.12.0 — the full-page Coach route gates on `flags.coach` and // redirects to `/insights` when the operator master flag is off. - "src/app/insights/coach/page.tsx", + // v1.18.0 — moved to the standalone top-level `/coach` route. + "src/app/coach/page.tsx", // v1.15.20 — the proactive Coach-nudge cron short-circuits on // `flags.coach` (gate 1) so an operator kill-switch also silences // the nudges. Server-side cron, not a render path — the cascade diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index caedd7d9e..594a040ad 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -101,6 +101,7 @@ function buildUser(disableCoach: boolean): AuthUser { insurerIkNumber: null, insuranceNumber: null, cycleTrackingEnabled: false, + modules: {}, }; } @@ -295,7 +296,8 @@ describe("Coach per-user disableCoach invariant", () => { // v1.12.0 — the full-page Coach route mirrors the launch-button / // FAB gate: operator master flag OR per-user opt-out redirects the // page back to `/insights` instead of painting a dead chat shell. - "src/app/insights/coach/page.tsx", + // v1.18.0 — moved to the standalone top-level `/coach` route. + "src/app/coach/page.tsx", // The hook itself + its `useAuth`-backed reader. "src/hooks/use-disable-coach.ts", // Cross-cut gates owned by sibling invariants / route tests. @@ -305,10 +307,12 @@ describe("Coach per-user disableCoach invariant", () => { // writes the flag does NOT read `user.disableCoach` (it reads // `body.disableCoach`) and is intentionally not listed here. "src/app/api/auth/me/route.ts", - // - Settings → KI section: the toggle card reads + writes the + // - Settings → Coach section: the toggle card reads + writes the // flag; the section shell reads it to gate the Coach sub-cards. + // v1.18.0 — moved out of the AI/Assistent section into its own + // dedicated Coach settings entry. "src/components/settings/ai/disable-coach-card.tsx", - "src/components/settings/ai-section.tsx", + "src/components/settings/coach-section.tsx", // v1.7.0 W6 — the unified dashboard snapshot builder reads // `user.disableCoach` to gate the embedded daily briefing to // `briefingState: "disabled"`. Covered by the briefingState matrix @@ -317,6 +321,12 @@ describe("Coach per-user disableCoach invariant", () => { // builder. "src/lib/dashboard/snapshot.ts", "src/app/api/dashboard/snapshot/route.ts", + // v1.18.0 — the module enable/disable gate delegates the `coach` + // module to the SAME two-layer source of truth (`user.disableCoach` + // AND the operator assistant master flag) rather than owning a + // second copy in `modulePreferencesJson`. Covered by the coach- + // delegation cases in `src/lib/modules/__tests__/gate.test.ts`. + "src/lib/modules/gate.ts", ]; /** diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index efa480f9e..3f95a9576 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -412,8 +412,9 @@ describe("buildFhirDocumentBundle", () => { const adherence = observations.find((o) => o.code.coding?.some((c) => c.code === "71799-1"), ); - // PDF renders taken/total = 85/90 = 94.4%. - expect(adherence?.valueQuantity?.value).toBeCloseTo(94.4, 1); + // taken/total = 85/90 = 94.4% → rounded to the app's integer convention + // (PDF + FHIR now share one rounding source of truth). + expect(adherence?.valueQuantity?.value).toBe(94); }); it("emits one MedicationStatement per active medication", () => { diff --git a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts new file mode 100644 index 000000000..7686c700b --- /dev/null +++ b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; + +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { + GLUCOSE_TIR_LOINC, + GLUCOSE_GMI_LOINC, + GLUCOSE_MEAN_LOINC, + GLUCOSE_EA1C_LOINC, +} from "@/lib/fhir/loinc-map"; + +import { buildFhirDocumentBundle } from "../build-bundle"; +import type { FhirObservation } from "../types"; + +const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); + +function makeData(overrides?: Partial): DoctorReportData { + return { + period: { + days: 90, + since: "2026-02-02T00:00:00.000Z", + start: "2026-02-02T00:00:00.000Z", + end: "2026-05-03T12:00:00.000Z", + }, + patient: { + username: "sample-user", + dateOfBirth: "1985-06-15T00:00:00.000Z", + gender: "MALE", + heightCm: 182, + }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + mood: null, + glp1: null, + ...overrides, + }; +} + +function observationsOf(bundle: ReturnType) { + return bundle.entry + .map((e) => e.resource) + .filter((r): r is FhirObservation => r.resourceType === "Observation"); +} + +function codeOf(obs: FhirObservation): string | undefined { + return obs.code.coding?.[0]?.code; +} + +/** Dense daily readings over the window so the panel populates fully. */ +function denseReadings(): { measuredAt: Date; mgdl: number }[] { + const out: { measuredAt: Date; mgdl: number }[] = []; + for (let d = 0; d < 60; d += 1) { + for (let h = 0; h < 24; h += 1) { + out.push({ + measuredAt: new Date( + FIXED_NOW.getTime() - d * 86_400_000 - h * 3_600_000, + ), + // Mostly in-range with a wobble so CV% / TIR are real. + mgdl: 110 + ((d + h) % 7) * 8, + }); + } + } + return out; +} + +describe("clinical glucose-panel FHIR Observations", () => { + it("emits TIR / GMI / mean / eA1C Observations when glucose readings exist", () => { + const glucoseClinical = computeGlucoseClinicalMetrics(denseReadings(), { + now: FIXED_NOW, + windowDays: 90, + }); + expect(glucoseClinical.readingCount).toBeGreaterThan(0); + + const bundle = buildFhirDocumentBundle(makeData({ glucoseClinical }), { insuranceNumber: "A123456780" }, FIXED_NOW); + const codes = observationsOf(bundle).map(codeOf); + + expect(codes).toContain(GLUCOSE_TIR_LOINC); + expect(codes).toContain(GLUCOSE_GMI_LOINC); + expect(codes).toContain(GLUCOSE_MEAN_LOINC); + expect(codes).toContain(GLUCOSE_EA1C_LOINC); + }); + + it("emits a percent-valued, UCUM-coded TIR Observation", () => { + const glucoseClinical = computeGlucoseClinicalMetrics(denseReadings(), { + now: FIXED_NOW, + windowDays: 90, + }); + const bundle = buildFhirDocumentBundle(makeData({ glucoseClinical }), { insuranceNumber: "A123456780" }, FIXED_NOW); + const tir = observationsOf(bundle).find((o) => codeOf(o) === GLUCOSE_TIR_LOINC); + expect(tir).toBeDefined(); + expect(tir!.valueQuantity?.code).toBe("%"); + expect(tir!.valueQuantity?.value).toBeGreaterThan(0); + expect(tir!.valueQuantity?.value).toBeLessThanOrEqual(100); + }); + + it("emits NO clinical glucose Observation when the panel has no readings (module off / no data)", () => { + // Default makeData() has an empty (zero-reading) panel. + const bundle = buildFhirDocumentBundle(makeData(), { insuranceNumber: "A123456780" }, FIXED_NOW); + const codes = observationsOf(bundle).map(codeOf); + expect(codes).not.toContain(GLUCOSE_TIR_LOINC); + expect(codes).not.toContain(GLUCOSE_GMI_LOINC); + expect(codes).not.toContain(GLUCOSE_MEAN_LOINC); + expect(codes).not.toContain(GLUCOSE_EA1C_LOINC); + }); +}); diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index cd65a48af..d360a8e12 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -304,6 +304,21 @@ export const GLUCOSE_LOINC: Record = BEDTIME: { loinc: "2339-0", display: "Glucose [Mass/volume] in Blood" }, }; +/* ── Clinical glucose-panel LOINCs (v1.18.0) ─────────────────────────── + * The CGM/spot-reading composite metrics the doctor report computes by the + * one literature-locked engine. Each carries the published LOINC term where + * one exists; the variability CV% has no published LOINC and is emitted as a + * survey-category text-only concept by the builder (honest about what we have). + */ +/** Time in range 70–180 mg/dL [Battelino 2019]. */ +export const GLUCOSE_TIR_LOINC = "97510-2"; +/** Glucose Management Indicator (GMI) [Bergenstal 2018]. */ +export const GLUCOSE_GMI_LOINC = "97506-0"; +/** Mean glucose over the reporting period. */ +export const GLUCOSE_MEAN_LOINC = "97507-8"; +/** Estimated A1C (ADAG, derived from mean glucose). */ +export const GLUCOSE_EA1C_LOINC = "41995-2"; + /** Medication-adherence Observation LOINC. */ export const MEDICATION_ADHERENCE_LOINC = "71799-1"; /** Mood Observation LOINC (opt-in only). */ diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index 41c6e55dd..61cf6c327 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -17,7 +17,10 @@ * convention. All text is escaped plain text; never user-supplied HTML * (no markdown library, no `dangerouslySetInnerHTML`). */ -import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { + adherenceRatePercent, + type DoctorReportData, +} from "@/lib/doctor-report-data"; import { resolveGlucoseUnit, convertGlucose } from "@/lib/glucose"; import { LOINC_SYSTEM, @@ -29,6 +32,10 @@ import { BP_DIA_LOINC, BP_UNIT, GLUCOSE_LOINC, + GLUCOSE_TIR_LOINC, + GLUCOSE_GMI_LOINC, + GLUCOSE_MEAN_LOINC, + GLUCOSE_EA1C_LOINC, MEDICATION_ADHERENCE_LOINC, MOOD_LOINC, LMP_LOINC, @@ -511,6 +518,106 @@ export function observationsFromReportData( }); } + // --- Clinical glucose-panel Observations (v1.18.0) --------------------- + // The server-computed TIR / GMI / estimated-A1C / mean / CV% over the report + // period, by the one literature-locked engine the insights panel + coach + + // PDF consume. Emitted only when readings exist (the aggregator zeroes the + // panel when the glucose module is off, so an absent panel = module off OR no + // data). Each carries the published LOINC where one exists; CV% has none and + // rides a `survey` text-only concept. Percent metrics use UCUM `%`. + const clinical = data.glucoseClinical; + if (clinical && clinical.readingCount > 0) { + const pushClinical = ( + opts: { + loinc?: string; + text: string; + value: number; + unit: string; + ucum: string; + survey?: boolean; + }, + ) => { + obsSeq += 1; + push({ + resourceType: "Observation", + id: `obs-${obsSeq}`, + status: "final", + category: [categoryConcept(opts.survey ? "survey" : "laboratory")], + code: opts.loinc + ? { + coding: [ + { system: LOINC_SYSTEM, code: opts.loinc, display: opts.text }, + ], + text: opts.text, + } + : { text: opts.text }, + subject: patientRef, + effectiveDateTime: data.period.end, + valueQuantity: { + value: opts.value, + unit: opts.unit, + system: UCUM_SYSTEM, + code: opts.ucum, + }, + ...(clinical.isSpotEstimate + ? { + note: [ + { + text: "Spot-reading estimate from individual measurements, not a continuous-monitor profile.", + }, + ], + } + : {}), + }); + }; + + if (clinical.distribution) { + pushClinical({ + loinc: GLUCOSE_TIR_LOINC, + text: "Glucose time in range (70–180 mg/dL)", + value: Math.round(clinical.distribution.tir * 1000) / 10, + unit: "%", + ucum: "%", + }); + } + if (clinical.meanMgdl !== null) { + pushClinical({ + loinc: GLUCOSE_MEAN_LOINC, + text: "Mean glucose", + value: convertGlucose(clinical.meanMgdl, glucoseUnit), + unit: glucoseUnit, + ucum: glucoseUnit === "mmol/L" ? "mmol/L" : "mg/dL", + }); + } + if (clinical.gmi !== null) { + pushClinical({ + loinc: GLUCOSE_GMI_LOINC, + text: "Glucose Management Indicator (GMI)", + value: Math.round(clinical.gmi * 10) / 10, + unit: "%", + ucum: "%", + }); + } + if (clinical.estimatedA1c !== null) { + pushClinical({ + loinc: GLUCOSE_EA1C_LOINC, + text: "Estimated A1C", + value: Math.round(clinical.estimatedA1c * 10) / 10, + unit: "%", + ucum: "%", + }); + } + if (clinical.variability !== null) { + pushClinical({ + text: "Glucose variability (coefficient of variation)", + value: Math.round(clinical.variability.cv * 10) / 10, + unit: "%", + ucum: "%", + survey: true, + }); + } + } + // --- Lab-result Observations (v1.17.1) --------------------------------- // One `laboratory` Observation per recorded analyte. `analyte` + `unit` // are user-recorded free text (no closed LOINC enum), so the code is a @@ -556,7 +663,9 @@ export function observationsFromReportData( for (const [name, comp] of Object.entries(data.compliance)) { if (comp.total <= 0) continue; obsSeq += 1; - const rate = Math.round((comp.taken / comp.total) * 1000) / 10; + // Integer percent — the one canonical rounding the app card + PDF use, so + // a clinician sees the same adherence figure on every surface. + const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0; push({ resourceType: "Observation", id: `obs-${obsSeq}`, diff --git a/src/lib/gamification/achievements.ts b/src/lib/gamification/achievements.ts index e2ba567e2..a4a7ae47e 100644 --- a/src/lib/gamification/achievements.ts +++ b/src/lib/gamification/achievements.ts @@ -80,6 +80,32 @@ export interface AchievementDefinition { category: AchievementCategory; } +/** + * v1.18.0 — module ownership for an achievement metric. Returns the + * `ModuleKey` whose enable/disable state governs the badge, or `null` + * when the badge belongs to a core domain (vitals, medications) or to + * an account-wide capability (security, login, bug reports) that has no + * toggleable module behind it. + * + * The string literals here intentionally mirror `MODULE_KEYS` in + * `@/lib/modules/registry` without importing it, so this pure lib stays + * free of the gate's transitive dependencies. The module gate (B5) reads + * this map to skip badge categories whose owning module is turned off — + * a sleep badge must not unlock while the sleep module is disabled. + */ +export function moduleForMetric(metric: AchievementMetricKey): string | null { + switch (metric) { + case "moodEntryCount": + case "moodDayStreak": + case "moodImprovementHit": + return "mood"; + case "sleepLogDayStreak": + return "sleep"; + default: + return null; + } +} + /** * v1.4.18 — discovery flags. Predicate input for * `applyDiscoveryFilter`. A flag is true iff the user has at least one diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 69637f6e4..d8e7a8c14 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -5,7 +5,8 @@ import { } from "@/lib/ai/prompts/medication-compliance"; import { buildComplianceMedicationContext, - calculateCompliance, + buildMedicationComplianceBundle, + dailyComplianceRatesFromLedger, lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; @@ -224,50 +225,34 @@ export async function generateMedicationComplianceStatusForUser( lastNonSkippedTakenAt(events), userTz, ); - const compliance7 = calculateCompliance( + // v1.18.0 — one shared cadence-aware expansion pass yields BOTH the + // canonical compliance7 / compliance30 (the values shown in this same + // snapshot) AND the unified dose-history ledger the per-day series is + // built from, so the series can never disagree with compliance30. + const bundle = buildMedicationComplianceBundle( events, medication.schedules, - 7, - medication.createdAt, - { medicationContext }, + medicationContext, + now, ); - const compliance30 = calculateCompliance( - events, - medication.schedules, - 30, - medication.createdAt, - { medicationContext }, + const compliance7 = bundle.compliance7; + const compliance30 = bundle.compliance30; + + // v1.18.0 — the per-day series honours the medication's cadence + // (daysOfWeek + intervalWeeks + rolling / RRULE / cyclic): only days the + // schedule actually expected a dose produce a point, and each day's rate + // uses the SAME taken / (taken + missed) tally as compliance30. The old + // path divided every event-day by `schedules.length`, treating every + // calendar day as fully dose-due — which made a weekly-cadence med's + // per-day series contradict its own compliance30. Reusing the canonical + // ledger fixes that by construction. + const perDayRecords = dailyComplianceRatesFromLedger(bundle.ledgerRows).map( + (day) => ({ + measuredAt: day.date, + value: round(day.rate, 1), + }), ); - // Collapse the events into one rate-per-day record, then run them - // through the shared bucketing helper so the model receives the - // canonical 360 daily + 24 monthly view per medication. - const byDay = new Map< - string, - { expected: number; taken: number; skipped: number; date: Date } - >(); - const expectedPerDay = Math.max(1, medication.schedules.length); - - for (const event of events) { - const dayKey = toBerlinDayKey(event.scheduledFor); - const bucket = byDay.get(dayKey) ?? { - expected: medication.schedules.length, - taken: 0, - skipped: 0, - date: event.scheduledFor, - }; - if (event.takenAt && !event.skipped) bucket.taken += 1; - if (event.skipped) bucket.skipped += 1; - byDay.set(dayKey, bucket); - } - - const perDayRecords = Array.from(byDay.values()) - .sort((a, b) => a.date.getTime() - b.date.getTime()) - .map((stats) => ({ - measuredAt: stats.date, - value: round(Math.min(100, (stats.taken / expectedPerDay) * 100), 1), - })); - // `applyPayloadBudget` gives the latest-day focus; the compact // graded series is what reaches the prompt. const dailyBudgeted = applyPayloadBudget(perDayRecords, { now }); diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts index 20f68e6af..64378ebef 100644 --- a/src/lib/jobs/__tests__/coach-nudge.test.ts +++ b/src/lib/jobs/__tests__/coach-nudge.test.ts @@ -45,6 +45,13 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({ decryptFromBytes: vi.fn(() => "morning blood pressure"), encryptToBytes: vi.fn(), })); +// The sleep-debt branch resolves the user's source priority through the +// real `@/lib/db` prisma; stub it so the unit suite stays DB-free. A +// null priority is the no-custom-priority default — `reconstructSleepNights` +// (pure) then runs normally on the stubbed measurement rows. +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); function dose(taken: boolean, skipped = false, autoMissed?: boolean) { return { diff --git a/src/lib/jobs/__tests__/cycle-reminder.test.ts b/src/lib/jobs/__tests__/cycle-reminder.test.ts index a6e83b7d1..e667ff408 100644 --- a/src/lib/jobs/__tests__/cycle-reminder.test.ts +++ b/src/lib/jobs/__tests__/cycle-reminder.test.ts @@ -33,6 +33,7 @@ import { PERIOD_CONFIRM_GRACE_DAYS, FERTILE_SOON_LEAD_DAYS, } from "../cycle-reminder"; +import { isCycleEnabled } from "@/lib/cycle/gate"; import type { NotificationPayload } from "@/lib/notifications/types"; import type { DispatchOutcome } from "@/lib/notifications/dispatcher"; @@ -297,13 +298,38 @@ describe("buildCycleReminderPayload", () => { }); describe("runCycleReminderTick", () => { - function run(state: FakeState, dispatch: DispatchFn, now = AT_0900_BERLIN) { + function run( + state: FakeState, + dispatch: DispatchFn, + now = AT_0900_BERLIN, + overrides: { + /** + * v1.18.0 — inject the module gate. Default resolves the per-user + * cycle toggle off the FakeState user (the same `isCycleEnabled` + * derivation the cron used to apply inline), so the gender-derivation + * cases stay faithful. Pass `operatorOff: true` to assert the operator + * server-wide kill-switch suppresses every push regardless of the + * per-user toggle. + */ + operatorOff?: boolean; + } = {}, + ) { const prisma = makePrisma(state); + const isModuleEnabled = vi.fn(async (userId: string) => { + if (overrides.operatorOff) return false; + const pred = state.predictions.find((p) => p.userId === userId); + const u = pred?.user; + return isCycleEnabled(u?.gender ?? null, u?.cycleProfile ?? null); + }); return runCycleReminderTick( // eslint-disable-next-line @typescript-eslint/no-explicit-any prisma as any, now, - { dispatch: dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification }, + { + dispatch: dispatch as typeof import("@/lib/notifications/dispatcher").dispatchNotification, + isModuleEnabled: + isModuleEnabled as unknown as typeof import("@/lib/modules/gate").isModuleEnabled, + }, ); } @@ -447,6 +473,26 @@ describe("runCycleReminderTick", () => { const summary = await run(state, dispatch); expect(dispatch).not.toHaveBeenCalled(); expect(summary.dispatchedPeriodSoon).toBe(0); + expect(summary.skippedModuleDisabled).toBe(1); + }); + + it("operator server-wide kill-switch suppresses every cycle push (v1.18.0)", async () => { + // A fully cycle-enabled FEMALE account with an in-window prediction: + // the only thing stopping the push is the operator-level module gate. + const state: FakeState = { + predictions: [ + { userId: "u1", nextPeriodStart: "2026-05-19", user: baseUser() }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch, AT_0900_BERLIN, { + operatorOff: true, + }); + expect(dispatch).not.toHaveBeenCalled(); + expect(summary.dispatchedPeriodSoon).toBe(0); + expect(summary.skippedModuleDisabled).toBe(1); }); it("opts a non-FEMALE account in via the explicit cycleTrackingEnabled flag", async () => { diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index d64cd34aa..22f3ad6fe 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -295,6 +295,68 @@ describe("runMeasurementReminderTick", () => { ); }); + it("v1.18.0 — skips a glucose reminder when the Glucose module is off, and advances", async () => { + const { prisma, updates } = makePrisma({ + reminders: [reminder({ measurementType: "BLOOD_GLUCOSE" })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + const isModuleEnabled = vi.fn(async () => false); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch, isModuleEnabled }, + ); + + expect(summary.skippedModuleDisabled).toBe(1); + expect(summary.dispatched).toBe(0); + expect(dispatch).not.toHaveBeenCalled(); + expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose"); + // Advances past the cycle so it does not re-evaluate every tick. + expect(updates).toHaveLength(1); + expect(updates[0].data.nextDueAt).toBeInstanceOf(Date); + }); + + it("v1.18.0 — still dispatches a glucose reminder when the Glucose module is on", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({ measurementType: "BLOOD_GLUCOSE" })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + const isModuleEnabled = vi.fn(async () => true); + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch, isModuleEnabled }, + ); + + expect(summary.skippedModuleDisabled).toBe(0); + expect(summary.dispatched).toBe(1); + expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose"); + }); + + it("v1.18.0 — a core-vital (BP) reminder never consults the module gate and dispatches", async () => { + const { prisma } = makePrisma({ + reminders: [reminder({ measurementType: "BLOOD_PRESSURE_SYS" })], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + const isModuleEnabled = vi.fn(async () => false); // even if everything is "off" + + const summary = await runMeasurementReminderTick( + prisma as never, + NINE_LOCAL, + { dispatch, isModuleEnabled }, + ); + + expect(summary.skippedModuleDisabled).toBe(0); + expect(summary.dispatched).toBe(1); + // A core domain has no ModuleKey — the gate is never consulted. + expect(isModuleEnabled).not.toHaveBeenCalled(); + }); + it("skips a reminder outside its notify-hour window", async () => { const { prisma } = makePrisma({ reminders: [reminder({ measurementType: null })], diff --git a/src/lib/jobs/__tests__/mood-reminder.test.ts b/src/lib/jobs/__tests__/mood-reminder.test.ts index 448ea8528..0fb0ad20a 100644 --- a/src/lib/jobs/__tests__/mood-reminder.test.ts +++ b/src/lib/jobs/__tests__/mood-reminder.test.ts @@ -46,6 +46,14 @@ const NO_CHANNEL: DispatchOutcome = { channelsSucceeded: 0, }; +/** + * v1.18.0 — default module-gate stub for the dispatch-path tests: every + * module reads as enabled, so these tests exercise the window / ledger / + * locale contract unchanged. The disabled-module skip has its own test + * that injects a gate returning `false`. + */ +const MODULE_ON = async () => true; + interface FakePrismaState { candidates: Array<{ id: string; @@ -254,6 +262,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(1); @@ -292,6 +301,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, at09, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(1); @@ -317,6 +327,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -338,6 +349,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(1); @@ -357,6 +369,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, outOfWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -374,7 +387,10 @@ describe("runMoodReminderTick", () => { const prisma = makePrisma(state); const dispatch = vi.fn(async () => OK); - await runMoodReminderTick(prisma as never, inWindow, { dispatch }); + await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + isModuleEnabled: MODULE_ON, + }); expect(prisma.user.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { moodReminderEnabled: true } }), @@ -395,6 +411,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -417,6 +434,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -438,6 +456,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(0); @@ -463,6 +482,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); // The dispatcher delivered (OK), the ledger insert hit P2002. The @@ -492,6 +512,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(1); @@ -514,6 +535,7 @@ describe("runMoodReminderTick", () => { const summary = await runMoodReminderTick(prisma as never, inWindow, { dispatch, + isModuleEnabled: MODULE_ON, }); expect(summary.dispatched).toBe(2); @@ -526,6 +548,52 @@ describe("runMoodReminderTick", () => { expect(titles).toContain("Log your mood"); }); + it("v1.18.0 — skips a user who turned the Mood module off", async () => { + const state: FakePrismaState = { + candidates: [{ id: "u-off", timezone: "Europe/Berlin", locale: "de" }], + moodEntries: [], + dispatches: [], + raceUserIds: new Set(), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => OK); + // Module disabled for this user. + const isModuleEnabled = vi.fn(async () => false); + + const summary = await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + isModuleEnabled, + }); + + expect(summary.skippedModuleDisabled).toBe(1); + expect(summary.dispatched).toBe(0); + expect(dispatch).not.toHaveBeenCalled(); + expect(state.dispatches).toHaveLength(0); + expect(isModuleEnabled).toHaveBeenCalledWith("u-off", "mood"); + }); + + it("v1.18.0 — still dispatches when the Mood module is enabled", async () => { + const state: FakePrismaState = { + candidates: [{ id: "u-on", timezone: "Europe/Berlin", locale: "de" }], + moodEntries: [], + dispatches: [], + raceUserIds: new Set(), + }; + const prisma = makePrisma(state); + const dispatch = vi.fn(async () => OK); + const isModuleEnabled = vi.fn(async () => true); + + const summary = await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + isModuleEnabled, + }); + + expect(summary.skippedModuleDisabled).toBe(0); + expect(summary.dispatched).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(isModuleEnabled).toHaveBeenCalledWith("u-on", "mood"); + }); + it("dispatches per-user locale for fr/es/it/pl (regression: resolver dropped 4/6)", async () => { const state: FakePrismaState = { candidates: [ @@ -541,7 +609,10 @@ describe("runMoodReminderTick", () => { const prisma = makePrisma(state); const dispatch = vi.fn(async () => OK); - await runMoodReminderTick(prisma as never, inWindow, { dispatch }); + await runMoodReminderTick(prisma as never, inWindow, { + dispatch, + isModuleEnabled: MODULE_ON, + }); const titles = dispatch.mock.calls.map((c) => c[0].title); const enTitle = buildMoodReminderPayload("en").title; diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index db20cf094..baea8459a 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -4,7 +4,7 @@ * One daily cron tick (05:15 Europe/Berlin, after the nightly score * passes have settled) evaluates a small set of DETERMINISTIC triggers * per user and — when one fires — dispatches a single `COACH_NUDGE` - * notification deep-linking to `/insights/coach`. No AI call happens + * notification deep-linking to `/coach`. No AI call happens * here: the triggers are plain arithmetic over already-persisted rows; * the Coach conversation the nudge invites the user into is where the * model gets involved. @@ -78,6 +78,9 @@ import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; import { getEvent } from "@/lib/logging/context"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; +import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; // Trend thresholds shared with the dashboard hero's verdict resolver // live in a client-safe leaf module; re-exported below so server-side // imports keep their path. @@ -551,9 +554,19 @@ export async function findTriggerForUser( } } - // 5) Sleep-deficit series: per-night hours over the last 7 days, - // longest record per calendar night (multi-source rows collapse - // to the fullest one). + // 5) Sleep-deficit series: per-night asleep HOURS over the last 7 + // days, reconstructed through the canonical sleep engine. + // + // `SLEEP_DURATION` is stored one row per STAGE per night, in + // MINUTES. The trigger compares against + // `effectiveRange("SLEEP_DURATION").greenMin`, which is in HOURS + // (7). The prior inline model kept each night's MAX single stage + // row (a fragment, not the night) AND fed minutes straight into + // the hours comparison — so a real ~360-480 minute night never + // cleared `< 6.5` and the nudge could never fire. Reusing + // `reconstructSleepNights` gives the proper asleep total + // (CORE+DEEP+REM, multi-source de-duped, user-tz wake-day keyed); + // dividing by 60 converts to the hours the trigger expects. const sleepRows = await prisma.measurement.findMany({ where: { userId: user.id, @@ -561,20 +574,25 @@ export async function findTriggerForUser( deletedAt: null, measuredAt: { gte: sevenDaysAgo, lte: now }, }, - select: { value: true, measuredAt: true }, + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, }); if (sleepRows.length > 0) { - const byNight = new Map(); - for (const row of sleepRows) { - const night = row.measuredAt.toISOString().slice(0, 10); - const prev = byNight.get(night); - if (prev === undefined || row.value > prev) { - byNight.set(night, row.value); - } - } + const sourcePriority = await loadUserSourcePriority(user.id); + const nights = reconstructSleepNights( + sleepRows, + user.timezone ?? DEFAULT_TIMEZONE, + sourcePriority, + ); + const nightlyHours = nights.map((n) => n.asleepMinutes / 60); if ( evaluateSleepDebtTrigger( - [...byNight.values()], + nightlyHours, effectiveRange("SLEEP_DURATION")?.greenMin ?? null, ) ) { @@ -809,7 +827,7 @@ export async function runCoachNudgeTick( trigger, // Deep link — the web-push sender threads this into the // service-worker payload's click target. - url: "/insights/coach", + url: "/coach", }, }); diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts index 24bc8c809..66c13adbc 100644 --- a/src/lib/jobs/cycle-reminder.ts +++ b/src/lib/jobs/cycle-reminder.ts @@ -43,7 +43,7 @@ import { defaultLocale, locales } from "@/lib/i18n/config"; import { getUserTodayBounds } from "@/lib/tz/local-day"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dayDiff } from "@/lib/cycle/day-math"; -import { isCycleEnabled } from "@/lib/cycle/gate"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import type { EventType } from "@/lib/notifications/types"; import { isCycleReminderClientManaged } from "@/lib/validations/notification-prefs"; @@ -92,6 +92,7 @@ export interface CycleReminderSummary { skippedNoChannel: number; skippedOutsideWindow: number; skippedPredictionDisabled: number; + skippedModuleDisabled: number; skippedAlreadyLogged: number; failed: number; } @@ -129,6 +130,7 @@ function emptySummary(): CycleReminderSummary { skippedNoChannel: 0, skippedOutsideWindow: 0, skippedPredictionDisabled: 0, + skippedModuleDisabled: 0, skippedAlreadyLogged: 0, failed: 0, }; @@ -290,9 +292,18 @@ export async function runCycleReminderTick( now: Date, options: { dispatch?: typeof dispatchNotification; + /** + * v1.18.0 module gate — injectable so the unit tests pin the + * operator/user disabled-module skip without booting the gate's DB + * reads. Defaults to the real `isModuleEnabled` resolver. The `cycle` + * ModuleKey resolves the per-user toggle AND the operator server-wide + * kill-switch, mirroring the mood / measurement reminder crons. + */ + isModuleEnabled?: typeof isModuleEnabled; } = {}, ): Promise { const dispatchImpl = options.dispatch ?? dispatchNotification; + const moduleGate = options.isModuleEnabled ?? isModuleEnabled; const summary = emptySummary(); // Push the gating into the query so the per-tick cost scales with the @@ -362,10 +373,18 @@ export async function runCycleReminderTick( try { const profile = user.cycleProfile; - // Gate ENTIRELY on cycle tracking being enabled (gender-derived or - // explicit opt-in) — a disabled account never receives a cycle push. - if (!isCycleEnabled(user.gender, profile)) { - summary.skippedOutsideWindow += 1; + // Gate on the FULLY-resolved cycle module — the per-user toggle + // (gender-derived or explicit opt-in) AND the operator server-wide + // kill-switch. v1.18.0: the `cycle` ModuleKey resolves through + // `isModuleEnabled`, so an operator who disables the Cycle module + // (`AppSettings.moduleAvailabilityJson.cycle = false`) suppresses + // every CYCLE_PERIOD push for every account — the same two-layer gate + // the routes and the coach cycle block read. A per-user-disabled + // account is likewise skipped. The `profile` row read below still + // applies the `predictionEnabled` opt-out. + if (!(await moduleGate(user.id, "cycle"))) { + summary.skippedModuleDisabled += 1; + getEvent()?.addMeta("cycle_reminder.skipped_module_disabled", user.id); continue; } diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 2afa31687..230f398bb 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -36,11 +36,36 @@ import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { getEvent } from "@/lib/logging/context"; import { isMeasurementReminderClientManaged } from "@/lib/validations/notification-prefs"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import type { ModuleKey } from "@/lib/modules/registry"; import { computeReminderNextDueAt, type ReminderScheduleInput, } from "@/lib/measurement-reminders/scheduling"; +/** + * v1.18.0 — map a reminder's `measurementType` to the toggleable module + * that owns it, or `null` when the type is a CORE domain (weight, blood + * pressure, pulse, body composition) or has no module of its own. A + * `null` reminder type (free-text Vorsorge entry) also yields `null`. + * + * Only the two secondary-domain types carry a gate: glucose and sleep. + * Everything else is core and dispatches regardless — disabling a module + * must never silence a core-vital reminder. + */ +function moduleForMeasurementType( + type: MeasurementType | null, +): ModuleKey | null { + switch (type) { + case "BLOOD_GLUCOSE": + return "glucose"; + case "SLEEP_DURATION": + return "sleep"; + default: + return null; + } +} + /** * Slack added to `now` when scanning for due reminders, so a `nextDueAt` * stamped just ahead of a tick still falls inside the same hour window. @@ -56,6 +81,7 @@ export interface MeasurementReminderSummary { autoResolved: number; skippedNotDue: number; skippedOutsideWindow: number; + skippedModuleDisabled: number; skippedClientManaged: number; skippedNoChannel: number; failed: number; @@ -133,9 +159,16 @@ export async function runMeasurementReminderTick( now: Date, options: { dispatch?: typeof dispatchNotification; + /** + * v1.18.0 module gate — injectable so the unit tests pin the + * disabled-module skip without the gate's DB reads. Defaults to the + * real `isModuleEnabled` resolver. + */ + isModuleEnabled?: typeof isModuleEnabled; } = {}, ): Promise { const dispatchImpl = options.dispatch ?? dispatchNotification; + const moduleGate = options.isModuleEnabled ?? isModuleEnabled; const summary: MeasurementReminderSummary = { candidatesScanned: 0, @@ -144,6 +177,7 @@ export async function runMeasurementReminderTick( autoResolved: 0, skippedNotDue: 0, skippedOutsideWindow: 0, + skippedModuleDisabled: 0, skippedClientManaged: 0, skippedNoChannel: 0, failed: 0, @@ -249,6 +283,26 @@ export async function runMeasurementReminderTick( summary.inWindow += 1; + // v1.18.0 module gate — a reminder whose measurement type belongs to + // a toggleable module (glucose / sleep) must not fire once the user + // turns that module off. Core-vital reminders (weight / BP / pulse / + // body comp) and free-text reminders map to no module and are never + // gated. Checked after the due + window gates so the gate read only + // fires for reminders that would otherwise dispatch this tick. + const gatedModule = moduleForMeasurementType(reminder.measurementType); + if (gatedModule !== null && !(await moduleGate(reminder.user.id, gatedModule))) { + getEvent()?.addMeta( + "measurement_reminder.skipped_module_disabled", + `${reminder.id}:${gatedModule}`, + ); + summary.skippedModuleDisabled += 1; + // Advance past this cycle so a disabled-module reminder does not pin + // the server tick re-evaluating the same overdue slot every 15 + // minutes for the rest of the day (the client-managed precedent). + await advanceNextDue(prisma, reminder, timezone, now); + continue; + } + // Per-user client-managed suppression (the medication precedent). if ( isMeasurementReminderClientManaged(reminder.user.notificationPrefs) diff --git a/src/lib/jobs/mood-reminder.ts b/src/lib/jobs/mood-reminder.ts index 20aa58604..cc3a0fe3f 100644 --- a/src/lib/jobs/mood-reminder.ts +++ b/src/lib/jobs/mood-reminder.ts @@ -27,6 +27,7 @@ import { defaultLocale, locales } from "@/lib/i18n/config"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { getEvent } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; import { DEFAULT_MOOD_REMINDER_HOUR, resolveMoodReminderHour, @@ -53,6 +54,7 @@ export interface MoodReminderSummary { skippedAlreadyLogged: number; skippedAlreadyDispatched: number; skippedOutsideWindow: number; + skippedModuleDisabled: number; skippedNoChannel: number; failed: number; } @@ -142,9 +144,16 @@ export async function runMoodReminderTick( now: Date, options: { dispatch?: typeof dispatchNotification; + /** + * v1.18.0 module gate — injectable so the unit tests pin the + * disabled-module skip without booting the gate's DB reads. Defaults + * to the real `isModuleEnabled` resolver. + */ + isModuleEnabled?: typeof isModuleEnabled; } = {}, ): Promise { const dispatchImpl = options.dispatch ?? dispatchNotification; + const moduleGate = options.isModuleEnabled ?? isModuleEnabled; const summary: MoodReminderSummary = { candidatesScanned: 0, @@ -153,6 +162,7 @@ export async function runMoodReminderTick( skippedAlreadyLogged: 0, skippedAlreadyDispatched: 0, skippedOutsideWindow: 0, + skippedModuleDisabled: 0, skippedNoChannel: 0, failed: 0, }; @@ -181,6 +191,15 @@ export async function runMoodReminderTick( summary.inWindow += 1; + // v1.18.0 module gate — a user who turned the Mood module off never + // receives its reminder. Checked after the window gate so the gate + // read only fires for the in-window cohort, not every opted-in user. + if (!(await moduleGate(user.id, "mood"))) { + summary.skippedModuleDisabled += 1; + getEvent()?.addMeta("mood_reminder.skipped_module_disabled", user.id); + continue; + } + // Cheaper guard first: the ledger lookup is a single indexed row // while the mood-entry scan touches today's mood writes. const existingDispatch = await prisma.moodReminderDispatch.findUnique({ diff --git a/src/lib/labs/__tests__/reference-range.test.ts b/src/lib/labs/__tests__/reference-range.test.ts new file mode 100644 index 000000000..fe56d3733 --- /dev/null +++ b/src/lib/labs/__tests__/reference-range.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; + +import { + classifyReferenceRange, + formatReferenceRange, +} from "@/lib/labs/reference-range"; + +describe("classifyReferenceRange", () => { + it("is unknown when no bounds are reported", () => { + expect(classifyReferenceRange(5, null, null)).toBe("unknown"); + expect(classifyReferenceRange(5, undefined, undefined)).toBe("unknown"); + }); + + it("treats both bounds as inclusive", () => { + expect(classifyReferenceRange(4, 4, 6)).toBe("in-range"); + expect(classifyReferenceRange(6, 4, 6)).toBe("in-range"); + expect(classifyReferenceRange(5, 4, 6)).toBe("in-range"); + }); + + it("flags below / above the full range", () => { + expect(classifyReferenceRange(3.9, 4, 6)).toBe("below"); + expect(classifyReferenceRange(6.1, 4, 6)).toBe("above"); + }); + + it("classifies against a high-only bound", () => { + expect(classifyReferenceRange(100, null, 116)).toBe("in-range"); + expect(classifyReferenceRange(120, null, 116)).toBe("above"); + }); + + it("classifies against a low-only bound", () => { + expect(classifyReferenceRange(8, 10, null)).toBe("below"); + expect(classifyReferenceRange(12, 10, null)).toBe("in-range"); + }); +}); + +describe("formatReferenceRange", () => { + // An identity-ish formatter keeps the structure assertions readable. + const fmt = (n: number) => String(n); + + it("renders a full low–high range with an en-dash and no spaces", () => { + expect(formatReferenceRange(4, 6, fmt)).toBe("4–6"); + }); + + it("renders a high-only bound with ≤", () => { + expect(formatReferenceRange(null, 116, fmt)).toBe("≤ 116"); + }); + + it("renders a low-only bound with ≥", () => { + expect(formatReferenceRange(10, null, fmt)).toBe("≥ 10"); + }); + + it("returns the empty string for no bounds by default", () => { + expect(formatReferenceRange(null, null, fmt)).toBe(""); + expect(formatReferenceRange(undefined, undefined, fmt)).toBe(""); + }); + + it("honours a custom emptyText for the no-bounds case", () => { + expect(formatReferenceRange(null, null, fmt, { emptyText: "—" })).toBe("—"); + }); + + it("defers digit formatting entirely to the callback", () => { + const oneDecimal = (n: number) => n.toFixed(1); + expect(formatReferenceRange(4, 6, oneDecimal)).toBe("4.0–6.0"); + }); +}); diff --git a/src/lib/labs/reference-range.ts b/src/lib/labs/reference-range.ts new file mode 100644 index 000000000..85369b176 --- /dev/null +++ b/src/lib/labs/reference-range.ts @@ -0,0 +1,63 @@ +/** + * v1.18.0 — single source of truth for lab reference-range classification + * and range-string structure. + * + * Three surfaces need the same logic: the API response (`rangeStatus`), the + * doctor-report PDF table, and the lab list card. They were drifting copies. + * The CLASSIFICATION (below / in-range / above / unknown) and the range + * STRUCTURE (`low–high` / `≤ high` / `≥ low` / empty) are shared here; the + * per-surface NUMBER FORMATTING stays a caller concern — the PDF wants a + * locale-aware one-decimal formatter, the list trims to int/2-dec — so + * `formatReferenceRange` takes a `formatNumber` callback and never imposes a + * formatter of its own. + */ + +/** + * Reference-range classification. Deliberately a three-state, NEUTRAL + * verdict — the badge that renders it must stay calm and informative, NOT + * alarming (no red "out of range" tint). `"unknown"` when the lab reported + * no usable bounds. + * + * Bounds are treated as inclusive: a value exactly on the reference limit + * reads as in-range, matching how labs print "≤" / "≥" reference notation. + */ +export type ReferenceRangeStatus = "in-range" | "below" | "above" | "unknown"; + +export function classifyReferenceRange( + value: number, + referenceLow: number | null | undefined, + referenceHigh: number | null | undefined, +): ReferenceRangeStatus { + const hasLow = referenceLow !== null && referenceLow !== undefined; + const hasHigh = referenceHigh !== null && referenceHigh !== undefined; + if (!hasLow && !hasHigh) return "unknown"; + if (hasLow && value < (referenceLow as number)) return "below"; + if (hasHigh && value > (referenceHigh as number)) return "above"; + return "in-range"; +} + +/** + * Render the reference range as text. Owns only the STRUCTURE — which of the + * four shapes (`low–high`, `≤ high`, `≥ low`, empty) applies — and defers + * every digit to the caller-supplied `formatNumber`, so each surface keeps + * its own number formatting byte-for-byte. + * + * `emptyText` is what to return when the lab reported no bounds at all; it + * defaults to the empty string (the lab list never renders the no-bounds + * case), while the PDF passes a neutral em-dash placeholder. + */ +export function formatReferenceRange( + low: number | null | undefined, + high: number | null | undefined, + formatNumber: (value: number) => string, + opts?: { emptyText?: string }, +): string { + const hasLow = low !== null && low !== undefined; + const hasHigh = high !== null && high !== undefined; + if (hasLow && hasHigh) { + return `${formatNumber(low as number)}–${formatNumber(high as number)}`; + } + if (hasHigh) return `≤ ${formatNumber(high as number)}`; + if (hasLow) return `≥ ${formatNumber(low as number)}`; + return opts?.emptyText ?? ""; +} diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts new file mode 100644 index 000000000..07ecfbd15 --- /dev/null +++ b/src/lib/modules/__tests__/gate.test.ts @@ -0,0 +1,300 @@ +/** + * v1.18.0 — module enable/disable gate resolver. + * + * Exercises the PURE resolver (`resolveModuleEnabled`) + the registry + * helpers, so the disabled-allowlist + delegation logic is asserted + * without a DB. The async `isModuleEnabled` / `requireModuleEnabled` + * thin wrappers are covered by the route test. + */ +import { describe, it, expect } from "vitest"; + +import { + resolveModuleEnabled, + normalisePrefs, + MODULE_DISABLED_ERROR_CODE, + type ModuleGateInputs, +} from "../gate"; +import { + resolveOperatorAvailability, + type OperatorModuleAvailability, +} from "../operator-availability"; +import { + isCoreDomain, + isModuleKey, + moduleDelegatesTo, + MODULE_KEYS, + CORE_DOMAIN_KEYS, +} from "../registry"; + +function inputs(over: Partial = {}): ModuleGateInputs { + return { + gender: null, + disableCoach: false, + modulePreferences: {}, + cycleTrackingEnabled: null, + ...over, + }; +} + +/** Operator availability map; default-available, overlay disables. */ +function operator( + disabled: Partial> = {}, +): OperatorModuleAvailability { + return resolveOperatorAvailability(disabled); +} + +/** All-available operator layer for the user-layer-focused cases. */ +const ALL_AVAILABLE = operator(); + +describe("resolveModuleEnabled — disabled allowlist (default-on)", () => { + it("enables a module when no preference is recorded", () => { + expect(resolveModuleEnabled("mood", inputs(), true, ALL_AVAILABLE)).toBe(true); + expect(resolveModuleEnabled("sleep", inputs(), true, ALL_AVAILABLE)).toBe(true); + expect(resolveModuleEnabled("labs", inputs(), true, ALL_AVAILABLE)).toBe(true); + }); + + it("enables a module when the key is present and true", () => { + expect( + resolveModuleEnabled("mood", inputs({ modulePreferences: { mood: true } }), true, ALL_AVAILABLE), + ).toBe(true); + }); + + it("disables a module ONLY on an explicit false", () => { + expect( + resolveModuleEnabled( + "glucose", + inputs({ modulePreferences: { glucose: false } }), + true, + ALL_AVAILABLE, + ), + ).toBe(false); + }); + + it("isolates per-module: disabling one leaves siblings on", () => { + const i = inputs({ modulePreferences: { workouts: false } }); + expect(resolveModuleEnabled("workouts", i, true, ALL_AVAILABLE)).toBe(false); + expect(resolveModuleEnabled("recovery", i, true, ALL_AVAILABLE)).toBe(true); + expect(resolveModuleEnabled("labs", i, true, ALL_AVAILABLE)).toBe(true); + }); +}); + +describe("resolveModuleEnabled — cycle delegation", () => { + it("ignores the module blob and reads the cycle gate (gender-derived)", () => { + // Even a crafted `{ cycle: false }` blob cannot override the real + // FEMALE-derived enablement — the gate delegates to isCycleEnabled. + const i = inputs({ + gender: "FEMALE", + cycleTrackingEnabled: null, + modulePreferences: { cycle: false }, + }); + expect(resolveModuleEnabled("cycle", i, true, ALL_AVAILABLE)).toBe(true); + }); + + it("respects the cycle toggle when set", () => { + expect( + resolveModuleEnabled( + "cycle", + inputs({ gender: "MALE", cycleTrackingEnabled: true }), + true, + ALL_AVAILABLE, + ), + ).toBe(true); + expect( + resolveModuleEnabled( + "cycle", + inputs({ gender: "FEMALE", cycleTrackingEnabled: false }), + true, + ALL_AVAILABLE, + ), + ).toBe(false); + }); + + it("a MALE account with a null toggle is cycle-disabled", () => { + expect( + resolveModuleEnabled("cycle", inputs({ gender: "MALE" }), true, ALL_AVAILABLE), + ).toBe(false); + }); +}); + +describe("resolveModuleEnabled — coach delegation (two-layer)", () => { + it("is enabled only when the operator master flag AND !disableCoach", () => { + expect( + resolveModuleEnabled("coach", inputs({ disableCoach: false }), true, ALL_AVAILABLE), + ).toBe(true); + }); + + it("is disabled when the per-user opt-out is set", () => { + expect( + resolveModuleEnabled("coach", inputs({ disableCoach: true }), true, ALL_AVAILABLE), + ).toBe(false); + }); + + it("is disabled when the operator master flag is off", () => { + expect( + resolveModuleEnabled("coach", inputs({ disableCoach: false }), false, ALL_AVAILABLE), + ).toBe(false); + }); + + it("ignores the module blob for coach", () => { + // A crafted `{ coach: true }` cannot re-enable against a disabled + // operator flag or a user opt-out. + const i = inputs({ disableCoach: true, modulePreferences: { coach: true } }); + expect(resolveModuleEnabled("coach", i, true, ALL_AVAILABLE)).toBe(false); + }); +}); + +describe("insights — narrative layer, not a delegated module", () => { + it("resolves from the disabled allowlist like the other secondary domains", () => { + expect(resolveModuleEnabled("insights", inputs(), true, ALL_AVAILABLE)).toBe(true); + expect( + resolveModuleEnabled( + "insights", + inputs({ modulePreferences: { insights: false } }), + true, + ALL_AVAILABLE, + ), + ).toBe(false); + }); + + it("is not delegated (no double source of truth marker)", () => { + expect(moduleDelegatesTo("insights")).toBeUndefined(); + }); +}); + +describe("resolveModuleEnabled — operator layer (two-layer AND)", () => { + it("operator-off ⇒ false even when the user has it on", () => { + // User layer fully on (no opt-out), operator disables it server-wide. + expect( + resolveModuleEnabled( + "mood", + inputs({ modulePreferences: { mood: true } }), + true, + operator({ mood: false }), + ), + ).toBe(false); + }); + + it("operator-on + user-off ⇒ false", () => { + expect( + resolveModuleEnabled( + "glucose", + inputs({ modulePreferences: { glucose: false } }), + true, + operator({ glucose: true }), + ), + ).toBe(false); + }); + + it("operator-on + user-on ⇒ true", () => { + expect( + resolveModuleEnabled("labs", inputs(), true, operator({ labs: true })), + ).toBe(true); + }); + + it("operator-off short-circuits a delegated module (coach) regardless of user state", () => { + // Even with the assistant master flag on and no per-user opt-out, the + // operator module-availability kill-switch wins. + expect( + resolveModuleEnabled( + "coach", + inputs({ disableCoach: false }), + true, + operator({ coach: false }), + ), + ).toBe(false); + }); + + it("operator-off short-circuits cycle even for a FEMALE account", () => { + expect( + resolveModuleEnabled( + "cycle", + inputs({ gender: "FEMALE", cycleTrackingEnabled: true }), + true, + operator({ cycle: false }), + ), + ).toBe(false); + }); + + it("isolates per-module: operator disabling one leaves siblings available", () => { + const op = operator({ workouts: false }); + expect(resolveModuleEnabled("workouts", inputs(), true, op)).toBe(false); + expect(resolveModuleEnabled("recovery", inputs(), true, op)).toBe(true); + expect(resolveModuleEnabled("sleep", inputs(), true, op)).toBe(true); + }); +}); + +describe("resolveOperatorAvailability — disabled allowlist (default-available)", () => { + it("treats null / non-object / array as all-available", () => { + for (const raw of [null, undefined, "nope", ["mood"]]) { + const map = resolveOperatorAvailability(raw); + for (const key of MODULE_KEYS) { + expect(map[key]).toBe(true); + } + } + }); + + it("disables a module ONLY on an explicit false; junk + true read as available", () => { + const map = resolveOperatorAvailability({ + mood: false, + sleep: true, + glucose: "yes", + labs: 0, + }); + expect(map.mood).toBe(false); + expect(map.sleep).toBe(true); + expect(map.glucose).toBe(true); + expect(map.labs).toBe(true); + }); + + it("returns an entry for every toggleable module", () => { + const map = resolveOperatorAvailability({}); + for (const key of MODULE_KEYS) { + expect(typeof map[key]).toBe("boolean"); + } + }); +}); + +describe("normalisePrefs — fail-open coercion", () => { + it("treats null / non-object / array as empty (all-on)", () => { + expect(normalisePrefs(null)).toEqual({}); + expect(normalisePrefs(undefined)).toEqual({}); + expect(normalisePrefs("nope")).toEqual({}); + expect(normalisePrefs(["mood"])).toEqual({}); + }); + + it("keeps only boolean values, dropping junk entries", () => { + expect( + normalisePrefs({ mood: false, sleep: "yes", glucose: 0, labs: true }), + ).toEqual({ mood: false, labs: true }); + }); +}); + +describe("registry — core domains can never be disabled", () => { + it("the four core domains are not toggleable module keys", () => { + for (const core of CORE_DOMAIN_KEYS) { + expect(isModuleKey(core)).toBe(false); + expect(isCoreDomain(core)).toBe(true); + } + }); + + it("a crafted core-domain key is inert (never read as a module)", () => { + // `weight: false` in the blob can't matter: weight is not a ModuleKey, + // so the gate never consults it. The resolver only ever runs for the + // 11 declared toggleable keys. + expect(isModuleKey("weight")).toBe(false); + expect(isModuleKey("medications")).toBe(false); + }); + + it("every toggleable key is a known module and not a core domain", () => { + for (const key of MODULE_KEYS) { + expect(isModuleKey(key)).toBe(true); + expect(isCoreDomain(key)).toBe(false); + } + }); +}); + +describe("wire contract", () => { + it("exports the iOS-classifier errorCode", () => { + expect(MODULE_DISABLED_ERROR_CODE).toBe("module.disabled"); + }); +}); diff --git a/src/lib/modules/gate.ts b/src/lib/modules/gate.ts new file mode 100644 index 000000000..3e4b8b7a3 --- /dev/null +++ b/src/lib/modules/gate.ts @@ -0,0 +1,257 @@ +/** + * v1.18.0 — module enable/disable gate. + * + * The ONE enforcement point for the module foundation. Every leak-point + * (nav, dashboard tiles, insights sections, coach snapshot, status + * routes, reminder jobs, achievements, doctor-report / FHIR, search, + * quick-add) resolves its decision through `isModuleEnabled(userId, key)` + * and `requireModuleEnabled(userId, key)`. Mirrors the cycle gate + * (`src/lib/cycle/gate.ts`) and the assistant feature flags + * (`src/lib/feature-flags/index.ts`). + * + * TWO-LAYER model (operator AND user). Every toggleable module's final + * enabled-state is `operatorAvailable(key) && userEnabled(key)`: + * + * - LAYER 1 (operator): the server-wide availability allowlist on + * `AppSettings.moduleAvailabilityJson`, resolved by + * `getOperatorModuleAvailability()`. A module the operator + * turns off is off for EVERY account regardless of personal + * preference — mirroring the coach master flag above + * `User.disableCoach`. Default-available. + * - LAYER 2 (user): the per-account opt-out described below. + * + * Source-of-truth discipline (NO double source of truth) for LAYER 2: + * + * - `cycle` delegates to `isCycleEnabled(gender, CycleProfile)` — the + * existing cycle gate. `modulePreferencesJson.cycle` is + * ignored on purpose. Still AND-ed with operator availability. + * - `coach` delegates to `User.disableCoach` AND the operator-level + * assistant master flag (`getAssistantFlags().coach`). Both + * must agree, matching the two-layer model the client gates + * already enforce. Also AND-ed with operator availability. + * - every other module resolves its user-layer state purely from + * `modulePreferencesJson` as a DISABLED allowlist (absent / + * empty / `true` ⇒ on; an explicit `false` ⇒ off). Default-on. + * + * Fail-closed posture: the resolver only ever DISABLES a module when it + * sees an explicit, well-typed `false` (or, for delegated modules, the + * delegated source returning disabled). A malformed blob, a missing + * column, an unknown key — none of these can silently disable a module + * (default-on), and none can enable a delegated one against its real + * source. A crafted `{ "weight": false }` blob is inert because `weight` + * is a core domain, not a `ModuleKey`. + * + * Reads are memoised per-request (the assistant-flags pattern) so a + * single request that gates several modules hits the row once. + */ +import { apiError } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { memoizePerRequest } from "@/lib/request-cache"; +import { + getOperatorModuleAvailability, + type OperatorModuleAvailability, +} from "./operator-availability"; +import { + MODULE_KEYS, + moduleDelegatesTo, + type ModuleKey, +} from "./registry"; + +export { + getOperatorModuleAvailability, + type OperatorModuleAvailability, +} from "./operator-availability"; + +export { MODULE_KEYS } from "./registry"; +export type { ModuleKey } from "./registry"; + +/** Wire `errorCode` the iOS retry classifier branches on. */ +export const MODULE_DISABLED_ERROR_CODE = "module.disabled"; + +/** + * The per-user fields the gate needs. Bundled so the resolver does a + * single round-trip and the memo caches one row per request. + */ +export interface ModuleGateInputs { + gender: string | null; + disableCoach: boolean; + modulePreferences: Record; + /** `CycleProfile.cycleTrackingEnabled`; NULL ⇒ derive from gender. */ + cycleTrackingEnabled: boolean | null; +} + +/** + * Coerce the persisted blob into a plain `Record`, + * keeping only boolean values. A null column, a non-object, or junk + * entries all collapse to an empty / partial map — which the + * disabled-allowlist semantics read as "all on". Fail-open per key: + * only a literal `false` disables. + */ +export function normalisePrefs(raw: unknown): Record { + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + const out: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === "boolean") { + out[k] = v; + } + } + return out; +} + +/** + * Pure resolver — exposed for unit tests so the two-layer (operator AND + * user) resolution can be asserted without a DB. + * + * `assistantCoach` is the resolved operator-level `getAssistantFlags().coach` + * and `operatorAvailability` is the resolved + * `getOperatorModuleAvailability()` map; both are passed in so this stays + * synchronous and side-effect-free. + * + * Resolution is `operatorAvailable(key) && userEnabled(key)`: + * + * - operator layer: an explicit operator `false` for the key disables it + * server-wide and short-circuits — no per-user state can re-enable it + * (matching the coach master flag winning over the per-user opt-out). + * - user layer: cycle/coach delegate to their real source; every other + * module reads the per-user disabled allowlist. + */ +export function resolveModuleEnabled( + key: ModuleKey, + inputs: ModuleGateInputs, + assistantCoach: boolean, + operatorAvailability: OperatorModuleAvailability, +): boolean { + // LAYER 1 — operator server-wide availability. An operator-disabled + // module is off for everyone regardless of personal preference. + if (operatorAvailability[key] === false) { + return false; + } + + // LAYER 2 — per-user state. + const delegate = moduleDelegatesTo(key); + + if (delegate === "cycle") { + // Single source of truth: the cycle gate. The module blob is ignored. + return isCycleEnabled(inputs.gender, { + cycleTrackingEnabled: inputs.cycleTrackingEnabled, + }); + } + + if (delegate === "coach") { + // Two-layer model: assistant master flag AND per-user opt-out. + return assistantCoach && !inputs.disableCoach; + } + + // Disabled allowlist: only an explicit `false` turns the module off. + return inputs.modulePreferences[key] !== false; +} + +/** Internal: load the gate inputs once per request. */ +function loadInputs(userId: string): Promise { + return memoizePerRequest(`module-gate-inputs:${userId}`, async () => { + const [user, cycleProfile] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { + gender: true, + disableCoach: true, + modulePreferencesJson: true, + }, + }), + prisma.cycleProfile.findUnique({ + where: { userId }, + select: { cycleTrackingEnabled: true }, + }), + ]); + return { + gender: user?.gender ?? null, + disableCoach: user?.disableCoach ?? false, + modulePreferences: normalisePrefs(user?.modulePreferencesJson), + cycleTrackingEnabled: cycleProfile?.cycleTrackingEnabled ?? null, + }; + }); +} + +/** + * Resolve whether a module is enabled for an account. Default-on: + * an unknown / absent / non-`false` state reads as enabled. Delegated + * modules (`cycle`, `coach`) resolve through their real source. + */ +export async function isModuleEnabled( + userId: string, + moduleKey: ModuleKey, +): Promise { + const [inputs, operatorAvailability] = await Promise.all([ + loadInputs(userId), + getOperatorModuleAvailability(), + ]); + const delegate = moduleDelegatesTo(moduleKey); + // Only pay the assistant-flags read when resolving the coach delegate. + const assistantCoach = + delegate === "coach" ? (await getAssistantFlags()).coach : false; + return resolveModuleEnabled( + moduleKey, + inputs, + assistantCoach, + operatorAvailability, + ); +} + +/** + * Resolve the full `{ : boolean }` map for every toggleable module, + * suitable for the `GET /api/auth/me` projection. cycle/coach reflect + * their real delegated state. + */ +export async function resolveModuleMap( + userId: string, +): Promise> { + const [inputs, operatorAvailability, assistantFlags] = await Promise.all([ + loadInputs(userId), + getOperatorModuleAvailability(), + getAssistantFlags(), + ]); + const assistantCoach = assistantFlags.coach; + const out = {} as Record; + for (const key of MODULE_KEYS) { + out[key] = resolveModuleEnabled( + key, + inputs, + assistantCoach, + operatorAvailability, + ); + } + return out; +} + +/** + * Route-guard outcome: either a clear pass or a ready 403 envelope to + * return verbatim — mirrors `CycleGateResult`. + */ +export type ModuleGateResult = + | { enabled: true } + | { enabled: false; response: Response }; + +/** + * Enforce the gate for a module-scoped route. Returns a 403 + * `module.disabled` envelope (carrying the module key in `meta`) when the + * account has the module turned off — even with a valid Bearer token. + */ +export async function requireModuleEnabled( + userId: string, + moduleKey: ModuleKey, +): Promise { + if (await isModuleEnabled(userId, moduleKey)) { + return { enabled: true }; + } + return { + enabled: false, + response: apiError(`Module "${moduleKey}" is not enabled`, 403, { + errorCode: MODULE_DISABLED_ERROR_CODE, + module: moduleKey, + }), + }; +} diff --git a/src/lib/modules/operator-availability.ts b/src/lib/modules/operator-availability.ts new file mode 100644 index 000000000..74d0c9ec1 --- /dev/null +++ b/src/lib/modules/operator-availability.ts @@ -0,0 +1,120 @@ +/** + * v1.18.0 — operator-level server-wide module availability. + * + * The SECOND layer of the two-layer module model. The first layer is the + * per-user opt-out (`User.modulePreferencesJson`, resolved in + * `./gate.ts`); this layer is the operator's server-wide kill-switch + * (`AppSettings.moduleAvailabilityJson`). A module the operator turns off + * is off for EVERY account regardless of that account's personal + * preference — exactly mirroring how the coach master flag + * (`assistant_coach_enabled`) sits above the per-user `User.disableCoach`. + * + * Persisted shape: a DISABLED allowlist, identical in semantics to the + * per-user blob — + * + * - NULL / empty / key absent ⇒ available + * - key present & `false` ⇒ disabled server-wide + * - key present & `true` ⇒ available (redundant-but-allowed) + * + * Default-available, no backfill: a fresh / upgraded instance keeps every + * module available until an operator flips one off. + * + * Fail-open posture: a null column, a malformed blob, an unknown key, or a + * read error all resolve to "available". Only an explicit, well-typed + * `false` for a known toggleable key disables a module server-wide. This + * matches the per-user resolver's posture so a storage glitch never + * silently hides a domain from the whole server. + * + * CORE domains (weight, BP, pulse, medications) are NOT `ModuleKey`s, are + * never written here, and the admin write endpoint refuses them — the + * measurement engine + meds can never be disabled. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { memoizePerRequest } from "@/lib/request-cache"; + +import { MODULE_KEYS, isModuleKey, type ModuleKey } from "./registry"; + +/** + * The resolved operator availability map: one boolean per toggleable + * module. `true` ⇒ available server-wide, `false` ⇒ disabled server-wide. + */ +export type OperatorModuleAvailability = Record; + +/** + * Coerce the persisted blob into a normalised availability map. Every + * known toggleable key gets an entry; absent / non-boolean / unknown keys + * collapse to `true` (available) per the disabled-allowlist contract. + * + * Exposed for unit tests so the coercion + allowlist logic can be asserted + * without a DB. + */ +export function resolveOperatorAvailability( + raw: unknown, +): OperatorModuleAvailability { + const blob = + raw != null && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : {}; + + const out = {} as OperatorModuleAvailability; + for (const key of MODULE_KEYS) { + // Only an explicit `false` disables; everything else is available. + out[key] = blob[key] !== false; + } + return out; +} + +/** + * Load the operator availability map once per request. Mirrors the + * `getAssistantFlags()` / `getGlobalServiceAvailability()` read-through: + * a missing row, a null column, or a read error all fall back to + * all-available so the modules stay visible on first boot. + */ +export async function getOperatorModuleAvailability(): Promise { + return memoizePerRequest("operator-module-availability", async () => { + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { moduleAvailabilityJson: true }, + }); + return resolveOperatorAvailability(settings?.moduleAvailabilityJson); + } catch { + getEvent()?.addWarning( + "Failed to load operator module availability, defaulting to all-available", + ); + return resolveOperatorAvailability(null); + } + }); +} + +/** + * Narrow + validate an operator write patch (the admin route's parsed + * body) into a sanitised `{ : boolean }` map. Drops any key + * that is not a known toggleable module — so a crafted `{ weight: false }` + * (a core domain) or junk key can never land in the persisted blob. + * + * Returns the merged blob to persist (existing availability overlaid with + * the validated patch) so a partial PATCH leaves untouched keys intact. + */ +export function mergeAvailabilityPatch( + existing: unknown, + patch: Record, +): Record { + // Start from the existing persisted blob, keeping only known boolean + // entries, then overlay the validated patch. + const merged: Record = {}; + if (existing != null && typeof existing === "object" && !Array.isArray(existing)) { + for (const [k, v] of Object.entries(existing as Record)) { + if (isModuleKey(k) && typeof v === "boolean") { + merged[k] = v; + } + } + } + for (const [k, v] of Object.entries(patch)) { + if (isModuleKey(k)) { + merged[k] = v; + } + } + return merged; +} diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts new file mode 100644 index 000000000..85e5b6cb7 --- /dev/null +++ b/src/lib/modules/registry.ts @@ -0,0 +1,232 @@ +/** + * v1.18.0 — canonical module registry. + * + * The single source of truth for "what is a module" in HealthLog's + * enable/disable foundation. Every leak-point (nav, dashboard tiles, + * insights sections, coach snapshot, status routes, reminder jobs, + * achievements, doctor-report / FHIR, search, quick-add) reads its gate + * decision through `src/lib/modules/gate.ts`, which keys off the entries + * declared here. + * + * Two classes of domain: + * + * - CORE (always-on): weight, blood pressure, pulse, medications — the + * measurement engine + meds. These are NOT in `MODULE_KEYS` and have + * NO registry entry. They can never be disabled; the gate has no key + * to flip and the write endpoint refuses them. This is structural, + * not a runtime check future code can soften. + * + * - TOGGLEABLE (the "secondary domains"): the maintainer-chosen scope + * below. Each carries a stable key, an i18n label + description key, + * a category for the Settings hub grouping, and an optional + * `delegatesTo` marker for the two modules whose enabled-state is + * owned elsewhere (no double source of truth). + * + * The persisted shape (`User.modulePreferencesJson`) is a DISABLED + * allowlist: absent / empty / `true` ⇒ enabled, an explicit `false` ⇒ + * disabled. Default-on, no backfill. + */ + +/** + * Where a module's enabled-state is resolved when it is NOT owned by + * `modulePreferencesJson` directly. Keeps a single source of truth: + * + * - "cycle" → `isCycleEnabled(gender, CycleProfile)` (the existing + * cycle gate; `modulePreferencesJson.cycle` is ignored). + * - "coach" → `User.disableCoach` AND the operator-level assistant + * master flag (`getAssistantFlags().coach`); again the + * module blob is ignored for this key. + * + * Every other toggleable module has no `delegatesTo` and resolves purely + * from `modulePreferencesJson`. + */ +export type ModuleDelegation = "cycle" | "coach"; + +/** Coarse grouping for the Settings "What you track" hub. */ +export type ModuleCategory = + | "tracking" // user-logged domains (mood, cycle, glucose, labs) + | "device" // device / passive-sync domains (sleep, workouts, recovery) + | "engagement" // gamification (achievements) + | "intelligence" // AI-driven surfaces (coach, insights) + | "export"; // outbound reporting (doctor report / FHIR) + +export interface ModuleDefinition { + /** Stable key — the literal used in `modulePreferencesJson` + the API. */ + key: ModuleKey; + /** i18n key for the human-readable module name (messages/*.json). */ + labelKey: string; + /** i18n key for the one-line "what turning this off does" explainer. */ + descriptionKey: string; + /** Settings-hub grouping. */ + category: ModuleCategory; + /** + * When set, the module's enabled-state is NOT resolved from + * `modulePreferencesJson` — the gate delegates to the named source so + * there is exactly one source of truth. See `ModuleDelegation`. + */ + delegatesTo?: ModuleDelegation; + /** + * For delegated modules only: where the real on/off control lives, so the + * Modules hub can render a read-only "managed in X" row that deep-links to + * the canonical control instead of a dead toggle. `href` is the in-app + * link; `labelKey` names the destination ("Account", "Coach settings"). + */ + managedAt?: { href: string; labelKey: string }; +} + +/** + * The toggleable modules — the maintainer's "secondary domains" scope. + * + * Declared as a const tuple so `ModuleKey` is the exact union and the + * registry stays exhaustively typed. + */ +export const MODULE_KEYS = [ + "cycle", + "mood", + "sleep", + "glucose", + "workouts", + "recovery", + "labs", + "achievements", + "coach", + "insights", + "doctorReport", +] as const; + +export type ModuleKey = (typeof MODULE_KEYS)[number]; + +/** + * CORE domains — the always-on measurement engine + medications. Listed + * here for documentation + the write-endpoint denylist; they have NO + * `ModuleDefinition` and can never appear as a toggle. A crafted + * `{ "weight": false }` blob is inert: `weight` is not a `ModuleKey`, so + * the gate never reads it and the PATCH validator rejects it. + */ +export const CORE_DOMAIN_KEYS = [ + "weight", + "bloodPressure", + "pulse", + "medications", +] as const; + +export type CoreDomainKey = (typeof CORE_DOMAIN_KEYS)[number]; + +const CORE_DOMAIN_SET: ReadonlySet = new Set(CORE_DOMAIN_KEYS); + +/** True for the four always-on core domains (never disableable). */ +export function isCoreDomain(key: string): key is CoreDomainKey { + return CORE_DOMAIN_SET.has(key); +} + +/** + * The registry. Order is the Settings-hub display order (grouped by + * category in the UI; this flat order is the canonical iteration order). + * + * `insights` decision (documented): disabling `insights` hides only the + * AI-ANALYSIS surfaces — the Daily Briefing, the per-metric AI status + * cards, the correlation narration, the Health-Score explainer. It does + * NOT hide the raw weight / BP / pulse charts or any measurement data: + * those are core and render from live measurements regardless. `insights` + * is the narrative layer, not the data layer. (The operator-level + * assistant master flag is a separate, server-wide kill-switch; this + * per-user toggle sits below it.) + * + * `coach` is the per-user opt-out half of the existing two-layer model + * (`User.disableCoach` AND the operator master flag). It is surfaced here + * so the Modules hub lists it alongside its siblings, but it delegates — + * the module blob never owns it. + */ +export const MODULE_REGISTRY: Readonly> = + Object.freeze({ + cycle: { + key: "cycle", + labelKey: "modules.cycle.label", + descriptionKey: "modules.cycle.description", + category: "tracking", + delegatesTo: "cycle", + // The real on/off lives in the Account section's cycle-tracking card. + managedAt: { + href: "/settings/account#cycle-tracking", + labelKey: "settings.sections.account.title", + }, + }, + mood: { + key: "mood", + labelKey: "modules.mood.label", + descriptionKey: "modules.mood.description", + category: "tracking", + }, + glucose: { + key: "glucose", + labelKey: "modules.glucose.label", + descriptionKey: "modules.glucose.description", + category: "tracking", + }, + labs: { + key: "labs", + labelKey: "modules.labs.label", + descriptionKey: "modules.labs.description", + category: "tracking", + }, + sleep: { + key: "sleep", + labelKey: "modules.sleep.label", + descriptionKey: "modules.sleep.description", + category: "device", + }, + workouts: { + key: "workouts", + labelKey: "modules.workouts.label", + descriptionKey: "modules.workouts.description", + category: "device", + }, + recovery: { + key: "recovery", + labelKey: "modules.recovery.label", + descriptionKey: "modules.recovery.description", + category: "device", + }, + achievements: { + key: "achievements", + labelKey: "modules.achievements.label", + descriptionKey: "modules.achievements.description", + category: "engagement", + }, + coach: { + key: "coach", + labelKey: "modules.coach.label", + descriptionKey: "modules.coach.description", + category: "intelligence", + delegatesTo: "coach", + // The real on/off lives in the dedicated Coach settings section. + managedAt: { + href: "/settings/coach", + labelKey: "settings.sections.coach.title", + }, + }, + insights: { + key: "insights", + labelKey: "modules.insights.label", + descriptionKey: "modules.insights.description", + category: "intelligence", + }, + doctorReport: { + key: "doctorReport", + labelKey: "modules.doctorReport.label", + descriptionKey: "modules.doctorReport.description", + category: "export", + }, + }); + +const MODULE_KEY_SET: ReadonlySet = new Set(MODULE_KEYS); + +/** True when `key` is a known toggleable module. */ +export function isModuleKey(key: string): key is ModuleKey { + return MODULE_KEY_SET.has(key); +} + +/** The two delegated keys, resolved by their existing source of truth. */ +export function moduleDelegatesTo(key: ModuleKey): ModuleDelegation | undefined { + return MODULE_REGISTRY[key].delegatesTo; +} diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 59eccc7cc..2ff484147 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -91,7 +91,7 @@ export async function sendViaWebPush( tag: payload.discreet ? "REMINDER" : payload.eventType, // v1.15.20 — opt-in deep link: a dispatcher payload may carry a // same-origin path in `metadata.url` (e.g. the Coach nudge's - // `/insights/coach`); everything else keeps the legacy "/" click + // `/coach`); everything else keeps the legacy "/" click // target. Only relative paths are honoured so a poisoned metadata // value can never point the SW at a foreign origin. url: diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index c6ee29446..ea23e1ce9 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -62,7 +62,7 @@ export const EVENT_TYPES = [ // v1.15.20 — proactive Coach nudge. Fired by the 05:15 Europe/Berlin // cron when a deterministic trigger (7-day compliance < 60 %, BP // weekly mean above target, sharply falling recovery score) warrants - // pointing the user at /insights/coach. Capped at one nudge per user + // pointing the user at /coach. Capped at one nudge per user // per rolling week via the `push_attempts` ledger; per-user opt-out // lives in `notificationPrefs.coach.nudgesEnabled`. "COACH_NUDGE", diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 19222890c..92e5653e3 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -13,6 +13,7 @@ import { INSIGHTS_SECTION_IDS, } from "@/lib/insights-layout"; import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts"; +import { coachChatRequestSchema } from "@/lib/ai/coach/types"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; @@ -147,7 +148,295 @@ const coachFactDeletedResponse = z.object({ ), }); +// ── Coach conversation history (v1.18.0) ───────────────────────────── +// List + detail + delete surface for the Coach's persisted chat +// conversations. Bodies are stored encrypted at rest; the detail +// endpoint decrypts every message server-side, so the client never +// handles a key. The list endpoint stays metadata-only (no decryption). + +const coachProvenanceSchema = z + .object({ + windows: z + .array( + z.enum([ + "last7days", + "last30days", + "last90days", + "lastYear", + "allTime", + ]), + ) + .describe("Analysis windows the assistant drew on this turn."), + metrics: z + .array(z.string()) + .describe( + "Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot sentinel. The client translates these labels; the server never localises them.", + ), + counts: z + .record(z.string(), z.number().int()) + .optional() + .describe("Per-metric sample-count summary; absent on an empty snapshot."), + keyValues: z + .array( + z.object({ + label: z.string(), + value: z.string(), + unit: z.string().optional(), + window: z.string().optional(), + }), + ) + .optional() + .describe( + "Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.", + ), + }) + .meta({ + id: "CoachProvenance", + description: + "Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values. No raw timestamps.", + }); + +const coachMessageSchema = z + .object({ + id: z.string(), + role: z.enum(["user", "assistant"]), + content: z + .string() + .describe("Decrypted message body — the server decrypts on read."), + createdAt: z.iso.datetime({ offset: true }), + metricSource: coachProvenanceSchema + .nullable() + .describe("Provenance envelope (assistant turns); null for user turns."), + providerType: z + .string() + .nullable() + .describe( + "Provider that produced the reply (e.g. anthropic, openai, local, refusal); null for user turns.", + ), + promptVersion: z + .string() + .nullable() + .describe("Coach prompt version that produced the reply; null for user turns."), + }) + .meta({ + id: "CoachMessage", + description: + "One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation.", + }); + +const coachConversationSchema = z + .object({ + id: z.string(), + title: z.string().describe("Title summarised from the first user message."), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z + .iso.datetime({ offset: true }) + .describe("Bumped on every appended message; the rail orders by this."), + messageCount: z.number().int(), + }) + .meta({ + id: "CoachConversation", + description: + "Lightweight conversation metadata for the history rail. No message bodies — the rail does not decrypt.", + }); + +const coachConversationsPageSchema = z + .object({ + conversations: z.array(coachConversationSchema), + nextCursor: z + .string() + .nullable() + .describe( + "Id of the last conversation on this page; pass it back as `cursor` for the next page. Null at the end of the list.", + ), + }) + .meta({ + id: "CoachConversationsPage", + description: + "Cursor-paginated page of the caller's Coach conversations, most-recent activity first.", + }); + +const coachConversationDetailSchema = z + .object({ + id: z.string(), + title: z.string(), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + messageCount: z.number().int(), + messages: z + .array(coachMessageSchema) + .describe("Every message in the conversation, decrypted, oldest-first."), + summary: z + .string() + .nullable() + .optional() + .describe( + "Rolling summary of turns elided past the history window; null when none is on file.", + ), + }) + .meta({ + id: "CoachConversationDetail", + description: + "Full conversation with every message decrypted server-side. The client renders the bodies directly; no decryption key is involved.", + }); + +const coachConversationDeletedResponse = z.object({ + deleted: z + .literal(true) + .describe("Always true on success; a foreign / unknown id is a 404."), +}); + +coachChatRequestSchema.meta({ + id: "CoachChatRequest", + description: + "Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). No `userId` field — the owner is narrowed from the session / Bearer.", +}); + export const coachPaths: NonNullable = { + "/api/insights/chat": { + get: { + tags: ["Insights"], + summary: "List the caller's Coach conversations", + description: + "v1.18.0 — cursor-paginated list of the caller's Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). Coach-gated (`requireAssistantSurface(\"coach\")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.", + parameters: [ + { + name: "cursor", + in: "query", + required: false, + schema: { type: "string" }, + description: + "Id of the last conversation on the previous page. Omit for the first page.", + }, + { + name: "limit", + in: "query", + required: false, + schema: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + description: "Page size. Defaults to 20, capped at 50.", + }, + ], + responses: { + "200": { + description: "A page of the caller's conversations.", + content: { + "application/json": { + schema: dataEnvelope( + coachConversationsPageSchema, + "CoachConversationsPageResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Insights"], + summary: "Send a Coach turn (streaming reply)", + description: + "v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: \\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `done` (`{ type, conversationId, messageId }`), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.", + requestBody: { + required: true, + content: { + "application/json": { schema: coachChatRequestSchema }, + }, + }, + responses: { + "200": { + description: + "Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames.", + content: { + "text/event-stream": { + schema: { + type: "string", + description: + "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes.", + }, + }, + }, + }, + "403": { + description: + "Coach surface disabled (`errorCode: assistant.disabled.coach`) or AI consent required (`errorCode: consent.ai.required`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Request body exceeds the 64 KB cap.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/chat/{id}": { + get: { + tags: ["Insights"], + summary: "Read one Coach conversation with all messages", + description: + "v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus the rolling `summary` when one is on file. The client renders the bodies directly; no decryption key is involved. A foreign / unknown id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Conversation id.", + }, + ], + responses: { + "200": { + description: "The conversation with all decrypted messages.", + content: { + "application/json": { + schema: dataEnvelope( + coachConversationDetailSchema, + "CoachConversationDetailResponse", + ), + }, + }, + }, + "404": { + description: "Conversation not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Insights"], + summary: "Delete one Coach conversation", + description: + "v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 (never 403). Coach-gated. Auth via cookie or Bearer.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Conversation id.", + }, + ], + responses: { + "200": { + description: "The conversation was deleted.", + content: { + "application/json": { + schema: dataEnvelope( + coachConversationDeletedResponse, + "CoachConversationDeleted", + ), + }, + }, + }, + "404": { + description: "Conversation not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/layout": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 9dbb92663..7a3b082ea 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -14,7 +14,7 @@ import { measurementSourceEnum, } from "@/lib/validations/measurement"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared"; const batchEntrySchema = z .object({ @@ -600,6 +600,7 @@ export const measurementPaths: NonNullable = { }, }, }, + ...moduleDisabledResponse, ...stdResponses, }, }, @@ -619,6 +620,7 @@ export const measurementPaths: NonNullable = { }, }, }, + ...moduleDisabledResponse, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 3a6f4224c..2a5654939 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -10,8 +10,45 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { coachPrefsSchema } from "@/lib/validations/coach-prefs"; import { notificationPrefsSchema } from "@/lib/validations/notification-prefs"; import { sourcePrioritySchema } from "@/lib/validations/source-priority"; +import { modulePrefsPatchSchema } from "@/lib/validations/modules"; +import { MODULE_KEYS } from "@/lib/modules/registry"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime +// validator (`modulePrefsPatchSchema` — strict over the toggleable key +// set; a core-domain or unknown key is a 422). The response is the +// fully-resolved `{ : boolean }` map for every toggleable +// module: `cycle` mirrors the cycle gate, `coach` mirrors the resolved +// `disableCoach` + operator master flag, the rest read the per-user +// disabled-allowlist. The map is the single thing a client needs to gate +// every secondary domain end-to-end. +const modulePrefsPatchRequest = modulePrefsPatchSchema.meta({ + id: "ModulePrefsPatchRequest", + description: + 'Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422, so the measurement engine + medications can never be disabled here. `cycle`/`coach` are accepted for forward-compat but their real enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the operator assistant flag).', +}); + +const moduleMapResolved = z + .object( + Object.fromEntries( + MODULE_KEYS.map((k) => [ + k, + z + .boolean() + .describe(`Whether the "${k}" module is enabled for this account.`), + ]), + ), + ) + .meta({ + id: "ModuleMap", + description: + "Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` mirrors the resolved per-user opt-out + operator master flag.", + }); + +const moduleMapEnvelopeInner = z + .object({ modules: moduleMapResolved }) + .meta({ id: "ModulesResponse" }); + // v1.4.49 — single schema for the per-user Coach opt-out flag. Previously // split into `disableCoachBody` (PATCH request) and `disableCoachData` // (response payload), both `z.object({ disableCoach: z.boolean() })`. The @@ -182,11 +219,15 @@ const profileResponse = z .string() .nullable() .describe("German KVNR, decrypted for the form prefill."), + // v1.18.0 — resolved module enable/disable map, identical to the + // /api/auth/me projection. The native client reads this alias, so it + // must carry the same server-authoritative module flags. + modules: moduleMapResolved, }) .meta({ id: "ProfileResponse", description: - "Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest.", + "Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest. The `modules` map mirrors the /api/auth/me projection so the alias carries the same module flags.", }); // The PATCH echo mirrors GET but reports KVNR presence as a boolean @@ -374,6 +415,54 @@ export const profilePaths: NonNullable = { }, }, }, + "/api/auth/me/modules": { + get: { + tags: ["Auth"], + summary: "Read the resolved per-user module enable/disable map", + description: + "Returns the fully-resolved `{ : boolean }` map for every toggleable module. `cycle` reflects the cycle gate, `coach` reflects the resolved per-user opt-out + operator master flag, and the rest read the per-user disabled-allowlist (`modulePreferencesJson`). Default-on: a fresh account reads every module enabled. iOS/web read this to hide a whole module surface end-to-end.", + responses: { + "200": { + description: "Resolved module map.", + content: { + "application/json": { + schema: dataEnvelope( + moduleMapEnvelopeInner, + "GetModulesResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Update per-user module enable/disable preferences", + description: + "Merges the supplied keys into the persisted disabled-allowlist (`modulePreferencesJson`) field-by-field — a PATCH touching only one module leaves the siblings intact. Refuses to disable a core module (`weight`, `bloodPressure`, `pulse`, `medications`) or any unknown key with a 422. `userId` is never accepted from the body. Always returns the fully-resolved next-state map so clients can hard-set their optimistic update. Rate-limit 60/min per user.", + requestBody: { + required: true, + content: { + "application/json": { schema: modulePrefsPatchRequest }, + }, + }, + responses: { + "200": { + description: "Resolved next-state module map echoed back.", + content: { + "application/json": { + schema: dataEnvelope( + moduleMapEnvelopeInner, + "PatchModulesResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/source-priority": { get: { tags: ["Auth"], diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 43c60cb71..599b305fd 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -124,3 +124,19 @@ export const consentRequiredResponse = { content: { "application/json": { schema: errorEnvelope } }, }, }; + +// ── Module-disabled gate (v1.18.0) ─────────────────────────────────── +// Every module-scoped route runs `requireModuleEnabled(userId, key)`, +// which returns a 403 when the account has the module turned off — even +// with a valid Bearer token. The envelope carries +// `meta.errorCode = "module.disabled"` and `meta.module` (the disabled +// module key) so the iOS retry classifier branches on it and the client +// can drop the whole surface. The errorEnvelope shape already declares +// `meta.errorCode`; `meta.module` is documented here in prose. +export const moduleDisabledResponse = { + "403": { + description: + 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.', + content: { "application/json": { schema: errorEnvelope } }, + }, +}; diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 5f604108e..d7472917e 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -23,6 +23,9 @@ export const adminKeys = { range: string | undefined, ) => ["admin", "app-logs", traceId, action, level, range] as const, adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const, + /** v1.18.0 — operator-level server-wide module availability matrix. */ + adminModuleAvailability: () => + ["admin", "settings", "module-availability"] as const, adminBackups: () => ["admin", "backups"] as const, adminCoachFeedback: () => ["admin", "coach-feedback"] as const, adminFeedback: (status: string) => ["admin", "feedback", status] as const, diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index 83fdd03c0..a085dd9ce 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -21,6 +21,15 @@ export const settingsKeys = { /** v1.8.5 — user-level injection-site preferences (global exclusion). */ injectionSitePrefs: () => ["settings", "injection-site-prefs"] as const, + /** + * v1.18.0 — the per-user module enable/disable map behind + * `GET/PATCH /api/auth/me/modules`. Used as the Module-hub mutation key; + * the resolved map itself rides on `authMe()` (the `/auth/me` payload), + * so the toggle mutation invalidates `authMe()` to re-gate the nav, + * Insights pills, and dashboard tiles in lockstep. + */ + modulesPrefs: () => ["settings", "modules"] as const, + apiVersion: () => ["api", "version"] as const, publicVersion: () => ["public", "version"] as const, /** v1.15.12 H1 — admin overview "update available" check against the diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index fde18bc7c..3a28ee7b2 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -145,25 +145,12 @@ export const listLabResultsSchema = z.object({ export type ListLabResultsInput = z.infer; /** - * Reference-range classification. Deliberately a three-state, NEUTRAL - * verdict — the badge that renders it must stay calm and informative, NOT - * alarming (no red "out of range" tint). `"unknown"` when the lab reported - * no usable bounds. - * - * Bounds are treated as inclusive: a value exactly on the reference limit - * reads as in-range, matching how labs print "≤" / "≥" reference notation. + * Reference-range classification lives in `@/lib/labs/reference-range` so the + * API response, the doctor-report PDF, and the lab list card all share one + * implementation. Re-exported here for the existing import sites. */ -export type ReferenceRangeStatus = "in-range" | "below" | "above" | "unknown"; - -export function classifyReferenceRange( - value: number, - referenceLow: number | null | undefined, - referenceHigh: number | null | undefined, -): ReferenceRangeStatus { - const hasLow = referenceLow !== null && referenceLow !== undefined; - const hasHigh = referenceHigh !== null && referenceHigh !== undefined; - if (!hasLow && !hasHigh) return "unknown"; - if (hasLow && value < (referenceLow as number)) return "below"; - if (hasHigh && value > (referenceHigh as number)) return "above"; - return "in-range"; -} +export { + classifyReferenceRange, + formatReferenceRange, + type ReferenceRangeStatus, +} from "@/lib/labs/reference-range"; diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts new file mode 100644 index 000000000..d33ae6870 --- /dev/null +++ b/src/lib/validations/modules.ts @@ -0,0 +1,49 @@ +/** + * v1.18.0 — module enable/disable request validation. + * + * The PATCH body is a partial map of TOGGLEABLE module keys → boolean. + * Only keys in the canonical registry (`MODULE_KEYS`) are accepted; a + * core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or + * any unknown key is a 422 — so the core measurement engine + meds can + * never be disabled through this surface (defence-in-depth on top of the + * gate, which has no key to flip for core domains anyway). + * + * `strict()` rejects unknown keys outright rather than silently dropping + * them, so a client gets a clear error instead of a no-op. + * + * Delegated keys (`cycle`, `coach`) are NOT accepted here: their + * enabled-state is owned elsewhere (the cycle gate / `disableCoach` + the + * operator assistant flag), so a value for them would land inert in + * `modulePreferencesJson` and mislead — the Modules hub renders them as + * read-only "managed in X" rows that deep-link to the real control. Omitting + * them from the schema means a stale client sending `{ coach: false }` gets + * a clear 422 instead of a silent no-op behind a green "saved" toast. + * + * The shared shape is reused by the OpenAPI registry so the wire + * contract stays single-source. + */ +import { z } from "zod/v4"; + +import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry"; + +/** + * The directly-owned (non-delegated) toggleable keys — the only keys this + * PATCH surface may write into `modulePreferencesJson`. + */ +const WRITABLE_MODULE_KEYS = MODULE_KEYS.filter( + (k) => moduleDelegatesTo(k) === undefined, +); + +/** Build `{ ?: boolean }` from the directly-owned key list. */ +const moduleShape = Object.fromEntries( + WRITABLE_MODULE_KEYS.map((k) => [k, z.boolean().optional()]), +) as Record<(typeof WRITABLE_MODULE_KEYS)[number], z.ZodOptional>; + +/** + * Partial module-preferences PATCH body. Every key is an optional + * boolean; omitted keys are left untouched server-side. `strict()` + * rejects any key not in `MODULE_KEYS` (including core domains). + */ +export const modulePrefsPatchSchema = z.object(moduleShape).strict(); + +export type ModulePrefsPatch = z.infer; diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 7075e163e..8fea366f3 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -110,9 +110,60 @@ async function seedUserWithEveryDataType(username: string): Promise { unit: "bpm", measuredAt, }, + // v1.18.0 module-gated data types (no `sections` toggle of their own). + { + userId: user.id, + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + glucoseContext: "FASTING", + measuredAt, + }, + { + userId: user.id, + type: "RECOVERY_SCORE", + value: 72, + unit: "score", + source: "WHOOP", + measuredAt, + }, + { + userId: user.id, + type: "STRESS_SCORE", + value: 40, + unit: "score", + measuredAt, + }, + { + userId: user.id, + type: "STRAIN_SCORE", + value: 12, + unit: "score", + measuredAt, + }, + { + userId: user.id, + type: "ACTIVITY_STEPS", + value: 8200, + unit: "count", + measuredAt, + }, ], }); + // Lab result — module `labs` owns these; the `labs` section toggle is ON by + // default so a present `labs` module surfaces the panel. + await prisma.labResult.create({ + data: { + userId: user.id, + panel: "Lipids", + analyte: "LDL", + value: 110, + unit: "mg/dL", + takenAt: measuredAt, + }, + }); + await prisma.moodEntry.create({ data: { userId: user.id, @@ -249,6 +300,107 @@ describe("doctor-report — per-section toggles", () => { }); }); +describe("doctor-report — module enable/disable gating", () => { + // A fully-enabled module map (the default-on shape `resolveModuleMap` would + // return for an account that never toggled a module off). + const ALL_ON = { + cycle: true, + mood: true, + sleep: true, + glucose: true, + workouts: true, + recovery: true, + labs: true, + achievements: true, + coach: true, + insights: true, + doctorReport: true, + } as const; + + it("includes every module's data when all modules are enabled", async () => { + const userId = await seedUserWithEveryDataType("dr-modules-all-on"); + + const data = await collectDoctorReportData(userId, RANGE, { + // Opt mood in so the present-case asserts the full surface. + sections: { mood: true }, + moduleMap: { ...ALL_ON }, + }); + + expect(Object.keys(data.glucoseStats).length).toBeGreaterThan(0); + expect(data.measurements.ACTIVITY_STEPS).toBeDefined(); + expect(data.measurements.BLOOD_GLUCOSE).toBeDefined(); + expect(data.labResults).not.toBeNull(); + expect(data.mood).not.toBeNull(); + const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type); + expect(scoreTypes).toEqual( + expect.arrayContaining([ + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", + ]), + ); + }); + + it("excludes a disabled module's section/resources from the payload", async () => { + const userId = await seedUserWithEveryDataType("dr-modules-off"); + + const data = await collectDoctorReportData(userId, RANGE, { + // The user opted mood in at the report level, but turned the modules off + // — the module gate wins, so none of these surface. + sections: { mood: true, sleep: true, labs: true }, + moduleMap: { + ...ALL_ON, + glucose: false, + sleep: false, + workouts: false, + recovery: false, + labs: false, + mood: false, + }, + }); + + // Glucose panel collapses entirely (stats + raw series). + expect(data.glucoseStats).toEqual({}); + expect(data.glucoseRanges).toEqual({}); + expect(data.measurements.BLOOD_GLUCOSE).toBeUndefined(); + // Sleep series stripped (sections + module agree). + expect(data.measurements.SLEEP_DURATION).toBeUndefined(); + // Workout activity series stripped. + expect(data.measurements.ACTIVITY_STEPS).toBeUndefined(); + expect(data.stats.ACTIVITY_STEPS).toBeUndefined(); + // Labs panel gone. + expect(data.labResults).toBeNull(); + // Mood never assembled. + expect(data.mood).toBeNull(); + // Recovery + strain wellness scores dropped (recovery + workouts off). + const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type); + expect(scoreTypes).not.toContain("RECOVERY_SCORE"); + expect(scoreTypes).not.toContain("STRESS_SCORE"); + expect(scoreTypes).not.toContain("STRAIN_SCORE"); + // Core clinical sections stay unconditional. + expect(data.stats.WEIGHT).toBeDefined(); + expect(data.stats.BLOOD_PRESSURE_SYS).toBeDefined(); + expect(data.stats.PULSE).toBeDefined(); + expect(Object.keys(data.compliance).length).toBeGreaterThan(0); + }); + + it("gates recovery and workouts independently (recovery off, workouts on)", async () => { + const userId = await seedUserWithEveryDataType("dr-modules-recovery-off"); + + const data = await collectDoctorReportData(userId, RANGE, { + moduleMap: { ...ALL_ON, recovery: false }, + }); + + const scoreTypes = (data.wellnessScores ?? []).map((s) => s.type); + // Recovery readiness scores gone… + expect(scoreTypes).not.toContain("RECOVERY_SCORE"); + expect(scoreTypes).not.toContain("STRESS_SCORE"); + // …but the workout-owned strain score + activity series stay. + expect(scoreTypes).toContain("STRAIN_SCORE"); + expect(data.measurements.ACTIVITY_STEPS).toBeDefined(); + }); +}); + describe("doctor-report — MedicationAdministration export cap", () => { it("caps the administration set at the configured ceiling and flags the truncation", async () => { // The default ceiling is 5000; pin the operator-configurable