fix(settings): make delegated module rows read-only managed deep-links

The Module hub rendered live switches for the delegated keys cycle and coach,
but the gate resolves those from their own sources (cycle from CycleProfile,
coach from the operator flag + disableCoach), so a toggle wrote an inert entry
to modulePreferencesJson, snapped back, and still fired a success toast. Render
delegated modules as read-only rows that deep-link to their real control, strip
delegated keys from the modules PATCH schema so no inert value is stored, drop
the duplicate cycle control, and surface an operator-disabled state with the
previously orphaned hint string.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 00:51:16 +02:00
1 parent 7c8d82f565
commit 91d01d154a
14 files changed
+216 -36

No files matched your search

+8 -5
View File
@@ -11,15 +11,18 @@
* returns the freshly-resolved module map.
*
* The body schema (`modulePrefsPatchSchema`) is `strict()` over the
* canonical toggleable key set, so a core-domain key (`weight`,
* `bloodPressure`, `pulse`, `medications`) or any unknown key is a 422 —
* the core measurement engine + meds can never be disabled here.
* 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.
* cycle/coach keys are accepted for forward-compat but the gate ignores
* the blob for them (they delegate to their existing source of truth).
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
+17 -2
View File
@@ -6,7 +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 } from "@/lib/modules/gate";
import {
resolveModuleMap,
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
@@ -22,7 +25,7 @@ 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, modules] = await Promise.all([
const [, cycleProfile, modules, moduleAvailability] = await Promise.all([
setOnboardingPendingCookie(user.onboardingCompletedAt == null),
prisma.cycleProfile.findUnique({
where: { userId: user.id },
@@ -34,6 +37,12 @@ export const GET = apiHandler(async () => {
// 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);
@@ -99,5 +108,11 @@ export const GET = apiHandler(async () => {
// 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,
});
});