feat(modules): add module_preferences_json disabled-allowlist column

Additive, nullable JSONB on users carrying a default-on disabled
allowlist for the optional (secondary) domains. NULL / empty / a key
set to true all read as enabled; a key set to false disables that
module. No backfill — every existing account reads all-on.

Core domains (weight, blood pressure, pulse, medications) are never
written here and have no toggle. cycle and coach keep their existing
source of truth and the column does not own them.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 20:53:46 +02:00
1 parent 56c92e0cfe
commit 6116d92ff5
2 files changed
+67

No files matched your search

@@ -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;
+29
View File
@@ -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`