mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #335 from MBombeck/release/v1.18.0
v1.18.0 — module enable/disable and the settings IA redesign
This commit is contained in:
226 files changed
+11541
-3464
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
+651
-14
@@ -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 `{ <moduleKey>: 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: <json>\\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: <json>\\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:
|
||||
|
||||
@@ -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`).
|
||||
@@ -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 });
|
||||
|
||||
+28
-12
@@ -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.
|
||||
|
||||
@@ -141,6 +141,26 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+132
-35
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
-36
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
-35
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
-35
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
-35
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
+132
-35
@@ -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ść."
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-7
@@ -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 [
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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: { <key>: 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")
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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 `<RecentAchievementsCard>` and
|
||||
// `<AchievementUnlockNotifier>` 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 (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
<AssistantSection />
|
||||
<ModuleAvailabilitySection />
|
||||
<AiServerKeySection />
|
||||
</SectionFrame>
|
||||
);
|
||||
|
||||
@@ -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<string> = [
|
||||
"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<string> = [
|
||||
// ── 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<string> = [
|
||||
"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, "<key>")`
|
||||
* 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<string> } {
|
||||
const keys = new Set<string>();
|
||||
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<ModuleKey>(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("<key>"), 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<string>(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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { availability: Record<string, boolean> };
|
||||
};
|
||||
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<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { availability: Record<string, boolean> };
|
||||
};
|
||||
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<string, boolean> };
|
||||
};
|
||||
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<string, boolean> })
|
||||
.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);
|
||||
});
|
||||
});
|
||||
@@ -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<z.ZodBoolean>>,
|
||||
)
|
||||
.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<string, boolean> = {};
|
||||
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),
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/modules/gate")>();
|
||||
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<Response>
|
||||
)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
glucoseByContext: Record<string, unknown> | 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<Response>
|
||||
)();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
glucoseByContext: Record<string, unknown> | null;
|
||||
glucoseClinical: unknown | null;
|
||||
summaries: Record<string, { count: number }>;
|
||||
};
|
||||
};
|
||||
// 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`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof import("@/lib/feature-flags")>();
|
||||
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<string, boolean>;
|
||||
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(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<Response>)(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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(
|
||||
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<Response>)(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<Response>)(
|
||||
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<Response>)(mkPatch({ labs: false }));
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { modulePreferencesJson: { sleep: false, labs: false } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* v1.18.0 — per-user module enable/disable preferences.
|
||||
*
|
||||
* GET /api/auth/me/modules — resolved `{ <moduleKey>: boolean }` map
|
||||
* for every toggleable module (cycle/coach
|
||||
* reflect their real delegated state).
|
||||
* PATCH /api/auth/me/modules — body `{ <moduleKey>?: 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<string, boolean> = 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 });
|
||||
});
|
||||
@@ -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. `{ <moduleKey>: 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,
|
||||
});
|
||||
});
|
||||
@@ -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: <T extends (...args: unknown[]) => 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<Envelope>;
|
||||
const put = PUT as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>;
|
||||
const getCtx = getSelfContextForUser as ReturnType<typeof vi.fn>;
|
||||
const gate = requireModuleEnabled as ReturnType<typeof vi.fn>;
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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<Envelope>;
|
||||
const del = DELETE as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
const baseHeaders: Record<string, string> = {
|
||||
"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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<PdfRequestBody | null> {
|
||||
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);
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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);
|
||||
});
|
||||
@@ -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<typeof import("@/lib/modules/gate")>();
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("@/lib/modules/gate")>();
|
||||
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<string> {
|
||||
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, boolean>,
|
||||
): Promise<Badge[]> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<AchievementsResult>,
|
||||
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<ReturnType<typeof requireAuth>>["user"];
|
||||
@@ -521,13 +587,18 @@ type AchievementsResult = Awaited<ReturnType<typeof buildAchievementsResult>>;
|
||||
* 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<ModuleKey, boolean>,
|
||||
) {
|
||||
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
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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(
|
||||
|
||||
@@ -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 `<InsightStatusCard>` mounts on each
|
||||
// /insights/<metric> sub-page. Both share the operator gate.
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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<Envelope>;
|
||||
const get = GET as unknown as (req: Request) => Promise<Envelope>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<Response> {
|
||||
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.
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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),
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>();
|
||||
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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Record<DerivedMetricId, ModuleKey>> = {
|
||||
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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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", () => {
|
||||
|
||||
@@ -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<Record<MetricStatusMetricId, ModuleKey>> = {
|
||||
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,
|
||||
|
||||
@@ -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<Response>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof import("@/lib/modules/gate")>()),
|
||||
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),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<MoodAggregates>,
|
||||
user.id,
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<Response>;
|
||||
@@ -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<string, boolean> };
|
||||
};
|
||||
expect(body.data.modules).toEqual(MODULE_MAP);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/user/profile", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -46,9 +46,19 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
|
||||
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.
|
||||
|
||||
@@ -44,9 +44,19 @@ vi.mock("next/headers", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/modules/gate")>();
|
||||
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 `<AuthShell>` (which provides the
|
||||
* `<CoachLaunchProvider>` 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 `<AuthShell>` (which provides the `<CoachLaunchProvider>`
|
||||
* 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();
|
||||
+10
-7
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BLOOD_GLUCOSE"
|
||||
|
||||
@@ -404,11 +404,16 @@ export default function InsightsPage() {
|
||||
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
|
||||
// tile, only for a cycle-tracking account, so its calendar read never
|
||||
// fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
|
||||
extraTile={user?.cycleTrackingEnabled ? <CycleRingTile /> : 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 ? <CycleRingTile /> : 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 ? (
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
import { Loader2, Moon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -35,9 +36,20 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
export default function InsightsSchlafPage() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const { ready } = useModulePageGuard("sleep");
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
|
||||
// /insights instead of half-rendering the sleep surface.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<SubPageShell
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { use } from "react";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkoutDetail } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
@@ -32,8 +35,18 @@ export default function InsightsWorkoutDetailPage({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { id } = use(params);
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, error } = useWorkoutDetail(id);
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Activity } from "lucide-react";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { WorkoutList } from "@/components/insights/workout-list";
|
||||
@@ -28,8 +29,18 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
*/
|
||||
export default function InsightsWorkoutsPage() {
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 });
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
|
||||
+8
-2
@@ -523,7 +523,11 @@ export default function DashboardPage() {
|
||||
const showBodyFatTile = isTileVisible("bodyFat") && hasBodyFat;
|
||||
const showMoodTile = isTileVisible("mood") && hasMood;
|
||||
const showSleepTile = isTileVisible("sleep") && hasSleep;
|
||||
const showStepsTile = isTileVisible("steps") && hasSteps;
|
||||
// v1.18.0 B1 — steps are activity data tied to the workouts module
|
||||
// surface; AND the resolved module flag (fail-open: only an explicit
|
||||
// `false` hides it) so a disabled-workouts account drops the tile.
|
||||
const showStepsTile =
|
||||
isTileVisible("steps") && hasSteps && user?.modules?.workouts !== false;
|
||||
const showVo2Tile = isTileVisible("vo2Max") && hasVo2;
|
||||
const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget;
|
||||
|
||||
@@ -559,7 +563,9 @@ export default function DashboardPage() {
|
||||
// no data floor, so it would otherwise flash in then retract for a user
|
||||
// who hid it.
|
||||
const showRecentWorkoutsTile =
|
||||
layoutResolved && isChartVisible("recentWorkouts");
|
||||
layoutResolved &&
|
||||
isChartVisible("recentWorkouts") &&
|
||||
user?.modules?.workouts !== false;
|
||||
|
||||
// v1.16.0 — shared reveal gate for the chart row. Every data-backed
|
||||
// chart mounts as soon as its visibility gate flips (so the per-chart
|
||||
|
||||
@@ -6,22 +6,22 @@ import { AboutSection } from "@/components/settings/about-section";
|
||||
import { AdvancedSection } from "@/components/settings/advanced-section";
|
||||
import { AiSection } from "@/components/settings/ai-section";
|
||||
import { ApiSection } from "@/components/settings/api-section";
|
||||
import { CoachSection } from "@/components/settings/coach-section";
|
||||
import { DashboardSection } from "@/components/settings/dashboard-section";
|
||||
import { ExportSection } from "@/components/settings/export-section";
|
||||
import { GesundheitsakteSection } from "@/components/settings/gesundheitsakte-section";
|
||||
import { InsightsSection } from "@/components/settings/insights-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { LayoutSection } from "@/components/settings/layout-section";
|
||||
import { MedicationsSection } from "@/components/settings/medications-section";
|
||||
import { ModulesSection } from "@/components/settings/modules-section";
|
||||
import { MoodSection } from "@/components/settings/mood-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { RemindersSection } from "@/components/settings/reminders-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
|
||||
// separate sections again. `ThresholdsSection` renders the target-range
|
||||
// editor; `SourcesSection` (standalone mode) renders the source-priority
|
||||
// ladders. Both are served by this dynamic route.
|
||||
// v1.18.0 (S3) — `sources` no longer has a standalone route; source priority
|
||||
// renders inside Settings → Integrations as the "Sources" sub-tab.
|
||||
// `/settings/sources` 301-redirects to `/settings/integrations` (next.config.ts).
|
||||
import { ThresholdsSection } from "@/components/settings/thresholds-section";
|
||||
import { SourcesSection } from "@/components/settings/sources-section";
|
||||
import {
|
||||
SETTINGS_SECTION_SLUGS,
|
||||
isSettingsSectionSlug,
|
||||
@@ -31,8 +31,7 @@ import { SettingsShell } from "@/components/settings/settings-shell";
|
||||
|
||||
/**
|
||||
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
|
||||
* (16 today — 12 nav-visible sections plus the four routable-but-hidden
|
||||
* Layout child editors) is pre-rendered at build via
|
||||
* is pre-rendered at build via
|
||||
* `generateStaticParams()` so the URLs are
|
||||
* statically known to Next.js, while the `dynamicParams = false` flag below
|
||||
* tells the router to 404 (instead of attempting on-demand rendering) for any
|
||||
@@ -51,19 +50,20 @@ const SECTION_COMPONENTS: Record<
|
||||
() => 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: () => <SourcesSection />,
|
||||
api: ApiSection,
|
||||
gesundheitsakte: GesundheitsakteSection,
|
||||
export: ExportSection,
|
||||
advanced: AdvancedSection,
|
||||
};
|
||||
|
||||
@@ -24,8 +24,8 @@ export default function VorsorgePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-6">
|
||||
<VorsorgeSection enabled={isAuthenticated} />
|
||||
<div className="space-y-6">
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ModuleKey, boolean>;
|
||||
|
||||
interface ModuleAvailabilityResponse {
|
||||
availability: AvailabilityMap;
|
||||
}
|
||||
|
||||
function useModuleAvailability() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.adminModuleAvailability(),
|
||||
queryFn: async () => {
|
||||
return apiGet<ModuleAvailabilityResponse>(
|
||||
"/api/admin/settings/module-availability",
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useUpdateModuleAvailability() {
|
||||
const client = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
patch: Partial<AvailabilityMap>,
|
||||
): Promise<ModuleAvailabilityResponse> => {
|
||||
return apiPatch<ModuleAvailabilityResponse>(
|
||||
"/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 (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={ToggleLeft}
|
||||
title={t("admin.modules.title")}
|
||||
description={t("admin.modules.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{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 (
|
||||
<SettingsToggle
|
||||
key={key}
|
||||
label={t(def.labelKey)}
|
||||
description={t(def.descriptionKey)}
|
||||
checked={available}
|
||||
onCheckedChange={(checked) =>
|
||||
mutation.mutate({ [key]: checked } as Partial<AvailabilityMap>)
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,23 +92,28 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
const hue = PHASE_HUE[wheel.phase];
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/cycle"
|
||||
data-slot="wellness-cycle-tile"
|
||||
data-metric="CYCLE"
|
||||
data-phase={wheel.phase}
|
||||
data-revealed={play ? "true" : undefined}
|
||||
// Match the score `RingTile` chrome: the phase's `--tile-hue` low-opacity
|
||||
// mix over `--card` + the faint film grain (the `.wellness-tile` family),
|
||||
// with a calm once-per-session rise. The per-phase hue arc inside the
|
||||
// ring is the only saturated thing on the tile.
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className={cn(
|
||||
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
|
||||
play && "wellness-tile-rise",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
// `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.
|
||||
<div data-revealed={play ? "true" : undefined} className="contents">
|
||||
<Link
|
||||
href="/cycle"
|
||||
data-slot="wellness-cycle-tile"
|
||||
data-metric="CYCLE"
|
||||
data-phase={wheel.phase}
|
||||
// Match the score `RingTile` chrome: the phase's `--tile-hue`
|
||||
// low-opacity mix over `--card` + the faint film grain (the
|
||||
// `.wellness-tile` family), with a calm once-per-session rise. The
|
||||
// per-phase hue arc inside the ring is the only saturated thing.
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className={cn(
|
||||
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
|
||||
play && "wellness-tile-rise",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-foreground flex items-center gap-2">
|
||||
<Droplets className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate text-base leading-none font-semibold">
|
||||
@@ -131,6 +136,7 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
{t(`cycle.phase.${wheel.phase}`)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -137,18 +137,21 @@ export function CycleView() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("cycle.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("cycle.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openSheet(today)} className="gap-2">
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("cycle.logToday")}</span>
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -156,20 +159,29 @@ export function CycleView() {
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
|
||||
{/* Wheel + compact phase card — grouped so they stick together. */}
|
||||
<div className="flex flex-col gap-4 lg:sticky lg:top-6">
|
||||
{/* 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. */}
|
||||
<div
|
||||
data-slot="cycle-wheel-tile"
|
||||
data-revealed={play ? "true" : undefined}
|
||||
style={
|
||||
tileHue
|
||||
? ({ "--tile-hue": tileHue } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
|
||||
play && "wellness-tile-rise",
|
||||
)}
|
||||
className="contents"
|
||||
>
|
||||
<div
|
||||
data-slot="cycle-wheel-tile"
|
||||
style={
|
||||
tileHue
|
||||
? ({ "--tile-hue": tileHue } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6",
|
||||
play && "wellness-tile-rise",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-[220px] items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
@@ -213,6 +225,7 @@ export function CycleView() {
|
||||
{t("cycle.ring.firstPeriodCta")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase-education card beneath the wheel — the single instance,
|
||||
|
||||
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user