Commit Graph
523 Commits
Author SHA1 Message Date
Marc-André Bombeck bc94a5893f feat(modules): default the illness journal on
The illness module shipped opt-in/default-off via a bornGated flag, the
inverse of the standard disabled-allowlist. Make it a normal optional
module that defaults on (opt-out), matching labs and its siblings.

No module used bornGated other than illness, so drop the flag, its
isBornGatedModule helper, and the gate resolver's born-gated branch
entirely. The module stays a normal toggle under What you track and the
operator-level availability override is unchanged; an account that wants
it off sets an explicit false. Refresh the gate, doctor-report, labs and
biomarkers comments plus the OpenAPI route descriptions accordingly.
2026-06-17 14:10:46 +02:00
Marc-André Bombeck 370b2f9c14 chore(cleanup): prune dead i18n keys, fix knip glob, scope Rest Mode to window
Remove 18 unreferenced i18n subtrees (75 leaves per locale, 450 strings
across de/en/es/fr/it/pl) left behind by the settings D0-D8 reshuffle and
the medication advanced/erweitert naming drift: settings.ai.disableCoach
(superseded by settings.coach.activate), doctorReport.dialog/sections, the
dead settings.sections export.hero / export.cards.doctorReport /
layout.medications / layout.mood / integrations.tabs subtrees and the
orphaned integrations connections/channels blurbs, the medications
detail.advanced.title/group + detail.edit duplicates, insights.recovery
.scoreLink, insights.range, targets.relativeDay (duplicate of
targets.consistency.relativeDay), and insights.subPage.primary /
lastMeasurement. Each subtree confirmed unused; the i18n coverage,
locale-integrity, and sections-parity suites stay green. The per-slug
settings.sections.<slug>.description keys are kept — they back the
SectionPlaceholder fallback and the parity test asserts them.

Point the stale knip entry at src/proxy.ts (renamed from middleware.ts)
and drop the no-match src/lib/health glob.

Resolve the analytics Rest Mode annotation as of the scored window: thread
a single scoredAt instant into both the health-score computation and
resolveRestMode, instead of letting the annotation read a later now().
2026-06-16 23:51:51 +02:00
Marc-André Bombeck 53dc7f76b5 refactor(jobs): decompose reminder worker into domain registrars; extract achievements result builder 2026-06-16 23:29:46 +02:00
Marc-André Bombeck 03034339d6 refactor(gamification): extract achievements builder out of the route
Move the day-series, completion-date, and streak helpers plus
buildAchievementsResult and the hidden-card spoiler shields into
src/lib/gamification/achievements-result.ts (verbatim). The route drops
from 1247 to 136 LOC and is now auth -> module gate -> cached ->
idempotent unlock-persistence -> apiSuccess (web + iOS format).

No behaviour change: the aggregation moved as-is, the cached factory
contract and the off-cache pendingUnlocks persistence are unchanged.
2026-06-16 23:28:55 +02:00
Marc-André Bombeck cdc76620fb fix(insights,dashboard): rest-mode web banner, sleep per-chart assessments, steps explainer, recovery heading level 2026-06-16 23:20:41 +02:00
Marc-André Bombeck 201686f498 fix(insights): surface Rest Mode on the web and align sleep assessments
Add a calm, value-free Rest Mode indicator to the dashboard score card and
the recovery insight so the web matches what iOS already shows: when an
illness/condition episode is active the score and recovery are framed, never
penalised, and the surface now says so. The new banner is neutral and
untinted, self-gates on an active episode, and reveals only the onset date —
never a decrypted note or episode label. The dashboard reads the annotation
from the snapshot (resolved server-side alongside the score); the recovery
section reads it from the analytics payload.

Give the sleep page one assessment per distinct chart group: the
duration/architecture assessment now sits with its charts, and the
sleep-quality block carries its own SLEEP_SCORE assessment, matching the
"multiple charts, multiple texts" structure the recovery page meets.

Add the steps explainer (insights.subPage.explainer.steps*) and pass
explainerMetric="steps", and demote the recovery per-block title to an h3 so
the heading outline nests cleanly under the page heading and group heading.
2026-06-16 23:20:04 +02:00
Marc-André Bombeck d92af094ed fix(illness): wire P3 engine to UI + OpenAPI, correctness (return-anchor, red-flag, fever, tz), FLARE/edit, guards 2026-06-16 23:18:40 +02:00
Marc-André Bombeck d7a698950c fix(illness): harden the episode + day-log write paths
Wrap episode-create in withIdempotency so an iOS retry can't mint duplicates.
Enforce resolvedAt >= onsetAt on create, edit, and the resolve verb (a partial
edit is checked against the stored onset). Run the day-log upsert, its symptom-
link replacement, and the read-back in one transaction and return the full DTO,
dropping the route's second round-trip. Restrict symptom severity to 1-3 (a
link's presence already means present, so 0 is not a distinct state). Return
the standard { deleted: true } envelope from DELETE instead of a bodyless 204.
2026-06-16 23:18:06 +02:00
Marc-André Bombeck e8e5ece6a1 fix(labs): auto-link biomarker on write, note-preserve, inverted-range guard, footer-pin, fail-soft decrypt, self-edit 2026-06-16 23:14:01 +02:00
Marc-André Bombeck 5514271668 fix(reminders,coach): BYHOUR multi-time, vorsorge edit + sheet, COACH label i18n, TOCTOU, origin/endsOn UI 2026-06-16 23:11:55 +02:00
Marc-André Bombeck 6df0c64d85 fix(labs): fail-soft the biomarker list context decrypt
The catalog GET eagerly threw on the first un-decryptable context row,
500ing the whole list. The list serializer now decrypts fail-soft (null
on a bad-key/malformed row) via decryptContextSoft, bounding the
fail-closed blast radius to one marker; the single-resource GET stays the
authoritative throwing read for the plaintext context.
2026-06-16 23:11:54 +02:00
Marc-André Bombeck 9141b8f74f fix(labs): guard partial bound edits against an inverted range
A PUT that moves a single reference bound past the row's stored other
bound slipped through the schema refine (which only fires when both bounds
arrive together). Both the lab-result and biomarker routes now merge the
effective bounds and 422 on low > high. Editing catalog-resolved fields
(analyte/unit/panel/range) on a linked reading is also rejected rather than
silently no-oped. Shared isInvertedRange/effectiveBound helpers with tests.
2026-06-16 23:11:48 +02:00
Marc-André Bombeck 796f6bacd6 fix(labs): auto-link lab readings to a biomarker on write
A free-text lab write resolves or mints a user-scoped biomarker by
(userId, lower(analyte)) so no LabResult row ever persists unlinked. An
existing marker is reused as-is (its unit/range stay authoritative); a
miss mints one from the reading's own spelling. Every reading is now
editable and detail-navigable, and the boot-time backfill becomes a pure
pre-upgrade migration. Closes the unlinked-row dead-end and the contract
hole in one path.
2026-06-16 23:11:28 +02:00
Marc-André Bombeck 7451e886f1 fix(vorsorge,coach): align reminders + coach chrome with the gold standard
Twice-daily BP protocol (RRULE BYHOUR=7,19) now fires at every clock
hour: byHourTimesOfDay expands BYHOUR into the engine's timesOfDay so
the label, RRULE, and engine output agree instead of collapsing to one
nudge at notifyHour.

Vorsorge surface:
- move create/edit into a ResponsiveSheet with a pinned Cancel/Save
  footer, matching Labs/Illness/Medications
- wire the unused PATCH support: Edit affordance opens the form
  pre-filled; an enable/disable Switch drives the enabled flag
- translate COACH-origin labels (the row stores an i18n key, not prose)
  and render a neutral Coach provenance badge + an until-<date> line
- surface origin + endsOn on the hook type
- pull-to-refresh + always-on rationale documented on the nav entry
- NativeSelect-vs-Radix choice documented as deliberate

Coach:
- expired COACH course-window reminders self-clean: the cron tick
  soft-deletes finished finite cadences instead of leaving dead rows
- accept-dedup gains a partial unique index (user, metric) where
  origin=COACH and not deleted, with a P2002 catch in the route
- per-user rate-limit bucket on the suggestion-action endpoint
- SUGGEST-REMINDER parser keeps prose written after the block
- document POST /api/coach/reminder-suggestions in OpenAPI

Coach chrome:
- HistoryRail hideHeading prop drops the duplicate Conversations
  heading on the page surface
- align the rail-tray strip inset to px-3 sm:px-4 so the dividers line
  up; reword the FAB inset comment

satisfyReminder closes the forward-only TOCTOU via a conditional
updateMany (count===0 no-op). Migration headers corrected to v1.18.1.
2026-06-16 23:11:08 +02:00
Marc-André Bombeck 88c4a8428f fix(admin): harden the Danger-Zone wipe to match the Backups Restore gate
The global data wipe auto-supplied confirm:'DELETE ALL' from a single-click
dialog, leaving the server's typed-token defence dead and making the most
destructive action a weaker gate than the less-destructive restore. Mirror
RestoreRowDialog: require typing 'DELETE ALL' into an Input that arms the
destructive button, reset on close.

Also attach an Idempotency-Key header to the wipe so a double-submit cannot
re-run the transaction, add the documented post-requireAdmin()
'admin-data-delete:<userId>' rate-limit bucket alongside the anonymous IP
throttle, and switch BackupsSection off the non-reactive formatDateTime
onto the locale-reactive useFormatters() hook its siblings use.
2026-06-16 23:05:11 +02:00
Marc-André Bombeck b28fa4d4a5 merge: suppress-during-illness Rest Mode + FHIR Condition + doctor-report 2026-06-16 22:27:13 +02:00
Marc-André Bombeck c59cf0b652 feat(illness): Rest Mode — annotate score/recovery/streaks during an active episode + FHIR/doctor-report
An active illness/condition episode annotates, it never penalises: the
measured number is untouched, only the narrative changes.

- rest-mode.ts: one server-authoritative resolver (module-gated, fail-soft)
  for "is the account in Rest Mode + which episodes back it". iOS mirrors the
  resolved context, never recomputes the suppression.
- health-score: /api/analytics attaches a value-free Rest Mode annotation;
  computeHealthScore stays pure (score never adjusted).
- recovery-score: computeRecoveryScore carries the Rest Mode context as of the
  scored day; the persisted blend is unchanged.
- coach-nudge: pause cadence-nudges during an active episode (new
  skippedDuringIllness counter, gate sits before the trigger search).
- achievements: bridgeFrozenStreakGaps freezes (does not break) every
  day-streak across illness days; never invents a streak, a non-illness gap
  still breaks.
- coincident-deviation: reframe a fired flag as illness-explained; vital
  numbers untouched. Surfaced as a neutral line on the insights card.
- coach snapshot: illness history + active illness as load-bearing context so
  the coach knows about Rest Mode (no decrypted note reaches the prompt).
- FHIR: illness episodes map to Condition (generic disease root, label on
  code.text, patient-reported/unconfirmed) + a bounding Encounter.
- doctor-report: module-gated illness section in the aggregator + PDF table
  (retrospective, neutral palette); FHIR Condition section in the bundle.

i18n x6 for the report + reframe copy. Tests: score-annotation-not-
penalisation, streak-freeze, coach-nudge pause, FHIR Condition/Encounter
mapping, doctor-report inclusion, Rest Mode resolver. typecheck/lint/test
green, openapi in sync, knip clean.
2026-06-16 22:25:50 +02:00
Marc-André Bombeck 6a74025b0a merge: illness correlation + recovery-gap engine with reliability battery 2026-06-16 22:17:29 +02:00
Marc-André Bombeck 745fa28c8b feat(illness): correlation + recovery-gap engine
Add the retrospective correlation engine for illness episodes: a pre-onset
anomaly scan (deviation in robust SDs from the user's own median±MAD
baseline), the nadir / what-dropped across the active span, and the
recovery-gap (physiological return-to-baseline vs the felt-better marker).
CHRONIC_ONGOING is excluded from the gap. Every derived number is gated by
the shared coverage/confidence model — a thin signal asserts nothing rather
than showing a wrong figure.

The baseline window ends strictly before the pre-onset lookback, so an
episode's own days can never contaminate the baseline it is measured against.
Reads are DAY-native (rollup tier with a live-SQL fallback), reusing the
baseline engine's read path so the rollup-vs-live parity carries over.

Surfaces:
- GET /api/illness/episodes/{id}/correlation — per-episode findings (gated
  Derived wire shape; red-flag escalation for sustained low SpO2 / fever).
- GET /api/illness/insights — cross-episode retrospective ("sick N times ·
  typical recovery gap X days"), with the typical-gap withheld below the
  min-sample floor. Retrospective only — never a predictor or diagnoser.

Reliability battery: golden-fixture recovery-gap math, rollup-vs-live parity,
baseline-contamination guard, thin-data / coverage-gate, red-flag escalation.
Born-gated + owner-scoped; server-authoritative DTO (iOS renders). i18n ×6.
2026-06-16 22:16:19 +02:00
Marc-André Bombeck 8447ad90cc merge: settings IA round 2 — top-text removal, integration split, medications toggleable, coach activate-flip, gesundheitsakte de-hero 2026-06-16 21:50:27 +02:00
Marc-André Bombeck 9b191a208d feat(settings): round-2 information-architecture rework
Apply the v1.18.0 walkthrough notes across the settings surface:

- Drop the explanatory top-text block from every settings section so
  content starts at one consistent vertical height (the sr-only heading
  stays for the accessibility tree).
- Move "Restart onboarding tour" out of Account into Advanced.
- Module hub: drop the explanatory line above the first card.
- Split the Integrations page into three left-side entries — the
  Connections content is now the Integrationen page itself, with
  Channels and Sources (source weighting) as their own entries. Drop the
  /settings/sources redirect (it is a real route again); Channels is a
  new entry.
- Notifications: drop the top text, the channels / inbox cross-links,
  and the embedded Vorsorge editor (it has its own page). Keep the mood,
  low-stock, and proactive-coach reminder cards.
- Rename the Layout hub entry to "Layout".
- Flip the Coach toggle to "Activate Coach" (default on, opt-out) over
  the same single disableCoach column — one source of truth.
- Move the AI "About me" context into Account → Profil, before
  cycle tracking.
- Gesundheitsakte: replace the gradient hero with a normal card.

i18n across all six locales for the renamed / added labels; settings
tests updated for the new structure.
2026-06-16 21:47:00 +02:00
Marc-André Bombeck bc2ce702f1 fix(insights): rebuild the recovery page onto the canonical metric structure
The recovery page rendered a grid of compact device-score tiles plus a
redundant cross-link to the recovery-score anatomy at the top. It now
follows the same structure as every other metric page (blood pressure,
pulse, the HealthKit scaffold): each present device-native signal renders
a block carrying a short explainer, the max / median / mean stat strip,
the shared chart with the identical 7 / 30 / 90 / All toggle, and a
per-chart assessment. Multiple charts now carry multiple matching texts.

Register the seven device-native recovery / strain signals (ANS charge,
day / workout strain, cardio load, whole-day average / max heart rate,
energy expenditure) in the generic metric-status registry so each chart
gets a generated assessment, gate them on the recovery module, and give
each its own chart-overlay slot. Drop the duplicate top score-link block
(this surface is reached from the overview already).

Regenerate the OpenAPI contract for the widened metric-status enum.
2026-06-16 21:35:09 +02:00
Marc-André Bombeck 559366e308 feat(settings): make medications a toggleable module
Medications graduated from the always-on core set (weight / blood
pressure / pulse stay core) to an opt-out module. The surface gates
off the resolved module map — the left-nav entry, the dashboard
medication widget, and the dedicated Medikamente settings entry hide
when the account turns the module off. The medication data routes stay
exempt from the gate (same data-layer reasoning as mood / labs): an
importer, a sync, and the user's ability to clean up entries keep
working while the surface is hidden, and re-enabling finds the
schedule and intake history intact.

Wire the module-route gate inventory, the dashboard widget/summary
maps, the PATCH allowlist, and the nav model; regenerate the OpenAPI
contract for the new module key.
2026-06-16 21:31:54 +02:00
Marc-André Bombeck ba8804cce0 merge: coach-cadence suggestions — SUGGEST-REMINDER sentinel, suggestion SSE frame, one-tap reminder 2026-06-16 21:19:36 +02:00
Marc-André Bombeck 12f4bc24e8 feat(coach): evidence-based cadence suggestions with one-tap in-chat reminder
The Coach can append a ---SUGGEST-REMINDER--- sentinel to propose a
measurement cadence (weight daily, the ESH/AHA 7-2-2 home-BP protocol,
structured glucose). The server resolves the cadence from a closed catalog
and emits an additive 'suggestion' SSE frame the chat renders as a one-tap
action card; accepting it creates a MeasurementReminder with origin COACH
through the same engine the Vorsorge surface uses (server-authoritative
schedule, finite course window self-expires via endsOn).

Non-naggy by construction: a frequency-cap cooldown, dismissal memory, a
master opt-out under Coach settings, and an explicit 'I measure enough'
stop path, all in the coach-prefs blob. Triple dedup at suggest, render,
and create so a cadence is never double-suggested or double-created, and
suggestions for a disabled module never surface. RHR/HRV stay passive --
the Coach surfaces the baseline, it never nudges to 'go measure' them.

The reminder DTO now echoes origin and endsOn for the iOS client.
2026-06-16 21:17:51 +02:00
Marc-André Bombeck 6ee0060c8a merge: illness/condition journal P2 MVP — born-gated module, episode CRUD, symptom timeline 2026-06-16 21:17:09 +02:00
Marc-André Bombeck cc32e2adf7 merge: Labs overhaul — biomarker catalog, structured entry, dashboard-style chart, edit/undo, backfill
# Conflicts:
#	src/app/api/labs/route.ts
2026-06-16 21:17:09 +02:00
Marc-André Bombeck b079431a8d feat(illness): add the condition-journal MVP behind a born-gated module
Introduce a retrospective illness/condition journal: episodes with a
lifecycle (acute / chronic-ongoing / recurring / flare) and a daily
symptom timeline forked structurally from the cycle log-day surface. The
journal is a record, not a medical device — no prediction or diagnosis UI.

- Register `illness` as a born-gated (opt-in / default-off) module. The
  gate resolver flips the disabled-allowlist default to off for born-gated
  keys, so the surface stays dark until the account opts in from the
  Modules hub. `isIllnessEnabled` + `requireIllnessEnabled` delegate to the
  module foundation (one source of truth) and re-stamp an illness errorCode.
- Episode CRUD under /api/illness: list / create / get / edit / resolve /
  delete, plus a per-episode-day day-log upsert on (episodeId, date). Every
  route wraps apiHandler, Zod-validates with returnAllZodIssues, narrows
  userId from auth, builds data field-by-field, and gates the module.
  Free-text notes encrypt at rest through the shared Bytes codec.
- Log-day sheet (cycle fork): ~8 Jackson/WURSS symptom chips 0-3 + a
  functional-impact group + fever + an encrypted note, one row per day.
- Episode history page + a born-gated nav entry, neutral palette, status
  via a neutral Badge, retrospective-only copy. i18n across all six locales.
- Cover the new routes in the module-route-gate inventory; unit tests for
  the day-log upsert, the gate enforcement, and the born-gated resolver.

The /api/auth/me module map now projects the `illness` flag (openapi.yaml
regenerated).
2026-06-16 21:09:35 +02:00
Marc-André Bombeck 90e0f2191c feat(labs): OpenAPI contract, i18n, and module-gate inventory
Add the Biomarker + restore routes to the OpenAPI registry and carry
biomarkerId on the LabResult schema (additive; iOS resolves the unit +
range from the linked marker, never recomputes). Regenerate the YAML.
Add the labs.* / labs.catalog.* keys across en/de/es/fr/it/pl. List the
biomarker catalog tree + restore route on the module-gate inventory's
data-layer exemptions (the module gates the surfaces, not the row store).
2026-06-16 21:06:16 +02:00
Marc-André Bombeck e9037a0d92 feat(labs): biomarker catalog with server-resolved reference ranges
Add a user-scoped Biomarker catalog so a marker is defined once — name,
unit, lower/upper bound, optional encrypted context, panel — and every
later reading just links it. New /api/biomarkers CRUD plus a structured
/api/labs create path: a biomarkerId resolves the unit + reference range
server-side, so the list and detail DTOs carry the resolved values and
the client never recomputes the range. Lab reads include the linked
marker and fall back to the legacy free-text fields for unlinked rows.

Add a lab-result restore endpoint for the delete-Undo affordance, a
shared serialiser, a static common-panel seed list, and tests for
catalog validation, structured-entry resolution, and the context codec.
2026-06-16 21:05:51 +02:00
Marc-André Bombeck 7b57c1d3d8 feat(vorsorge): resolve reminders on ingest, not only on the cron
Add a reminder-satisfy pg-boss queue (mirrors pr-detection) that every
measurement and lab ingest path enqueues — manual create, batch, and the
Withings / Polar / Oura / Nightscout / Fitbit / WHOOP syncs. A reminder
now auto-resolves the moment a matching reading lands instead of waiting
up to 15 minutes. The 15-minute cron stays as the idempotent safety net;
the forward-only guard lets the eventful hook and the cron converge
without double-stamping.

The worker honours the module toggle (a reminder for a disabled module
produces no engine activity) and runs the same satisfaction primitives
the cron uses.
2026-06-16 20:58:56 +02:00
Marc-André Bombeck f92247760c feat(vorsorge): one satisfaction primitive + lab auto-resolve
Extract a single satisfyReminder() that every path uses to mark a
reminder's cadence fulfilled: stamp lastSatisfiedAt (forward-only) and
recompute the server-authoritative nextDueAt. The cron auto-resolve and
the manual satisfy route now route through it instead of duplicating the
reschedule.

Add findSatisfyingEvent(): a typed reminder resolves from a matching
Measurement, a free-text reminder from any LabResult. This closes the
Lab<->Vorsorge loop — an annual-blood-panel reminder is now satisfied
when a lab result lands, not only on a manual check-off.
2026-06-16 20:58:46 +02:00
Marc-André Bombeck 3be34ceb11 merge(reconcile): b2-insightsai audit findings 2026-06-16 02:46:34 +02:00
Marc-André Bombeck 261f3cea1d test(insights): walk the insights AI tree in the module-route gate inventory
Add `src/app/api/insights` to the inventory's walked trees so every
`route.ts` beneath it must classify: the 14 AI-narrative routes as
insights-module-gated, the Coach sub-tree as coach-delegated, and the
AI-settings / provider-chain / tile-layout / feedback / targets /
glp1-timeline routes as explicit infra-and-config EXEMPT entries with
documented reasons. A future ungated insights AI route now fails the
inventory BY NAME rather than leaking the surface over a Bearer token when
the account turned insights off.

Add representative insights-off → 403 module.disabled coverage to the
blood-pressure-status and correlations route tests (alongside the existing
insights-on → 200 path).
2026-06-16 02:45:23 +02:00
Marc-André Bombeck 4cf923647d feat(insights): gate every AI-narrative insights route on the insights module
Require the `insights` module (in addition to the existing assistant-surface
gate and any per-metric data-module gate) on all 14 narrative insights
routes: blood-pressure / bmi / pulse / weight / medication-compliance /
mood / metric / derived / derived-batch status, cards, correlations,
narrative, pregenerate, and rhythm-events. A 403 module.disabled envelope
now precedes any provider or cache work when the account has insights off.

The per-metric sleep/glucose/recovery/mood gates and the
requireAssistantSurface("insightStatus"|"correlations") checks are kept;
this adds the module layer on top of them.

Route tests that now resolve the gate mock @/lib/modules/gate
default-enabled so their existing data-shape and assistant-flag assertions
ride through unchanged.
2026-06-16 02:40:38 +02:00
Marc-André Bombeck f73bc32a72 merge(reconcile): b5-achievements audit findings 2026-06-16 02:20:45 +02:00
Marc-André Bombeck 95f4b5a263 merge(reconcile): b3-doctorreport audit findings 2026-06-16 02:20:45 +02:00
Marc-André Bombeck 63901edea7 refactor(doctor-report): remove the orphaned legacy doctor-report surface
The DoctorReportDialog + its /api/doctor-report, /pdf and /availability routes
were referenced only by their own tests — the health-record (Gesundheitsakte)
export is the live surface. Remove the dead dialog, routes, their tests, and
the now-stale docs page; the shared doctor-report-data aggregator stays (the
health-record export uses it). The route-gate inventory exempt list is updated
accordingly.
2026-06-16 02:16:38 +02:00
Marc-André Bombeck 629e5396c6 test(modules): move gated workout read routes off the exempt inventory
The workout list + detail reads now carry requireModuleEnabled, so the
route-gate inventory's exempt allowlist must stop claiming them as
ungated data-layer surfaces. Only the iOS batch ingest stays exempt.
2026-06-16 02:07:34 +02:00
Marc-André Bombeck f72d0b0ef3 fix(achievements): skip disabled-module badges and decouple unlock writes from the cached read
Gate badge evaluation on the per-user module map: mood badges no longer
evaluate, render, or persist while the mood module is off, and the same
holds for any metric that maps to a toggleable module (sleep). Core and
account-wide badges have no owning module and always pass through.

Move unlock persistence out of the cached builder. The builder now
returns the not-yet-persisted unlocks and the handler runs the
idempotent createMany after the cache read, so a cache hit can no longer
skip the write and a concurrent miss can no longer duplicate it.

Add the parity fields the iOS DTO dropped (category, points, target,
current, isHidden) so the native client groups and renders badges in
lock-step with the web surface.
2026-06-16 02:01:47 +02:00
Marc-André Bombeck b6db529dee feat(doctor-report): wire the doctorReport module toggle to the health-record surface
The doctorReport module toggle was inert: turning it off hid nothing and the
server-side export route accepted requests regardless. Gate the live surface
on the module — the Health-record settings entry now hides client-side when
the module is off, and POST /api/export/health-record refuses with a 403
module.disabled envelope, so the doctor-report surface cannot be reached over a
Bearer token either.
2026-06-16 02:00:48 +02:00
Marc-André Bombeck 6eef3ab21c fix(workouts): gate the workout read APIs behind the workouts module
GET /api/workouts and GET /api/workouts/{id} returned a disabled-
module account's workout list and detail to any valid Bearer or
cookie session. Both now require the workouts module and return the
403 module.disabled envelope when it is off.

The batch INGEST route (POST /api/workouts/batch) stays ungated by
design: synced workouts must keep landing in storage even while the
surface is hidden, so re-enabling the module reveals a complete
history rather than a gap.
2026-06-16 01:57:54 +02:00
Marc-André Bombeck 8cc879f6dc fix(analytics): gate glucose clinical panel behind the glucose module
The default /api/analytics slice served the per-context glucose
summaries and the TIR / GMI / eA1C clinical panel unconditionally,
leaking a disabled-glucose account's readings to a surface the
account has turned off. The route also serves core weight / BP /
pulse metrics, so a 403 on the whole payload is wrong.

Resolve the glucose module once server-side and null out
glucoseByContext + glucoseClinical when it is off; the core metrics
keep flowing. Mirrors the mood-pillar gate already in this route.
2026-06-16 01:55:03 +02:00
Marc-André Bombeck e86ba40e5b merge(reconcile): g2-dashboard audit findings 2026-06-16 01:49:20 +02:00
Marc-André Bombeck 372554bc11 merge(reconcile): g1d-coachroutes audit findings 2026-06-16 01:49:20 +02:00
Marc-André Bombeck ca4952b2c8 fix(insights): gate the batch derived route on the module map
The batch derived endpoint (the one the dashboard wellness strip reads)
computed Recovery/Strain/Stress unconditionally while the single derived route
already gated them, so disabling the recovery module did not hide the rings.
Reuse the single route's DERIVED_MODULE map (now exported, with READINESS added
since it equals the computed recovery score) and drop disabled-module metrics
from the batch payload per request, without mutating the cached map.
2026-06-16 01:39:18 +02:00
Marc-André Bombeck 85b0b635f2 test(coach): cover the module gate on the chat and about-me routes
Per-route tests assert the two-layer Coach module gate: a disabled
module (operator availability OR per-user disableCoach) yields 403
module.disabled before any Coach work runs, and an enabled module falls
through to the normal path. Covers the chat POST + list GET, about-me
GET/PUT, the questions GET/DELETE, and the adopt POST short-circuit
ahead of the locked read-modify-write.
2026-06-16 01:28:28 +02:00
Marc-André Bombeck 064ed92071 fix(coach): enforce the module gate on the about-me routes
The structured self-context endpoints — about-me GET/PUT, the adopt
POST, and the clarifying-questions GET/DELETE — read and wrote Coach
self-context with no module check. Each handler now runs the two-layer
requireModuleEnabled(userId, "coach") gate right after auth, returning
403 module.disabled when the operator availability layer or the
per-user disableCoach opt-out has Coach turned off. The adopt route
test mocks the gate to stay focused on the locked read-modify-write.
2026-06-16 01:25:51 +02:00
Marc-André Bombeck 2b5974cf4a fix(coach): enforce the module gate on coach chat and list routes
The streaming POST and the conversation-list GET enforced only the
legacy operator assistant flag (requireAssistantSurface("coach")),
missing the per-user disableCoach opt-out and the operator
module-availability layer. Both handlers now run the two-layer
requireModuleEnabled(userId, "coach") gate right after auth, returning
403 module.disabled when the account has Coach turned off — even with a
valid Bearer token. The legacy assistant-flag check stays in place.
2026-06-16 01:24:20 +02:00
Marc-André Bombeck b5506bfc73 test(analytics): stub the module gate now that the health-score path resolves it
The health-score fast path resolves the per-user module map to drop the mood
pillar when the mood module is off; the analytics route exercises that path, so
its tests must stub the gate (default all-enabled) instead of hitting the
DB-backed resolver.
2026-06-16 01:06:54 +02:00