diff --git a/prisma/migrations/0167_module_enable_disable/migration.sql b/prisma/migrations/0167_module_enable_disable/migration.sql new file mode 100644 index 000000000..0a5a69144 --- /dev/null +++ b/prisma/migrations/0167_module_enable_disable/migration.sql @@ -0,0 +1,38 @@ +-- v1.18.0 — module enable/disable foundation. +-- +-- A single nullable JSONB blob on `users` carrying a DISABLED allowlist +-- for the optional ("secondary") domains (mood, sleep, glucose, workouts, +-- recovery, labs, achievements, insights, doctor-report, …). The contract +-- is default-on: +-- +-- * NULL column → every module enabled +-- * empty object `{}` → every module enabled +-- * key absent → that module enabled +-- * key present & `false` → that module DISABLED +-- * key present & `true` → enabled (redundant-but-allowed) +-- +-- No backfill — every existing user keeps every module on. The +-- application resolver (`src/lib/modules/gate.ts`) tolerates a missing +-- column / null row and reads all-on, so a partial deploy where the +-- schema lands ahead of (or behind) the app code keeps working. +-- +-- CORE domains (weight, blood pressure, pulse, medications) are never +-- written here and the write endpoint refuses to disable them, so the +-- measurement engine + meds can never be turned off via this column. +-- `cycle` and `coach` keep their existing source of truth +-- (`CycleProfile.cycle_tracking_enabled` / `users.disable_coach` + +-- the operator assistant master flag) — this column does not own them. +-- +-- No additional index — reads are per-user (`WHERE id = $1`), matching +-- the convention set by `coach_prefs_json`, `source_priority_json`, +-- `doctor_report_prefs_json`, and `notification_prefs` on the same table +-- (no GIN index on any of them). +-- +-- Idempotent guard (`IF NOT EXISTS`) so reruns are safe on prod. +-- +-- Reversibility: down migration is +-- ALTER TABLE "users" DROP COLUMN IF EXISTS "module_preferences_json"; +-- A roll-back loses per-user module opt-outs (every user falls back to +-- all-on), which is the safe default — no data is destroyed. +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "module_preferences_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbf1b8bf7..354486506 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -401,6 +401,35 @@ model User { // `useAuth()` hook. disableCoach Boolean @default(false) @map("disable_coach") + // v1.18.0 — module enable/disable foundation. + // + // A DISABLED allowlist for the optional ("secondary") domains. The + // contract is default-on: a NULL column, an empty object, or a key + // simply absent all mean "module enabled". A key present and set to + // `false` means that module is OFF; `true` is redundant-but-allowed. + // No backfill — every existing user reads as all-on. + // + // Shape (string keys ⇒ boolean): + // { "mood": false, "glucose": false, ... } + // + // The canonical key set + which keys are toggleable lives in + // `src/lib/modules/registry.ts`; the resolver + 403 guard live in + // `src/lib/modules/gate.ts`. CORE domains (weight, blood pressure, + // pulse, medications — the measurement engine + meds) are NEVER + // present here and the gate refuses to disable them, so a crafted + // `{ "weight": false }` blob cannot turn the core engine off. + // + // Two modules do NOT resolve from this column to avoid a double + // source of truth: `cycle` delegates to `CycleProfile`/`isCycleEnabled` + // and `coach` delegates to `User.disableCoach` + the operator-level + // assistant master flag. The column may still carry a `cycle`/`coach` + // key for forward-compat but the gate ignores it for those two. + // + // Persisted via `PATCH /api/auth/me/modules` with field-by-field + // merge semantics (no mass assignment); surfaced resolved on + // `GET /api/auth/me` as a `modules: { : boolean }` map. + modulePreferencesJson Json? @map("module_preferences_json") + // v1.5.5 — self-hosted avatar storage. // // Replaces the Gravatar third-party leak (the old `getGravatarUrl`