feat(api): surface and update per-user module preferences

GET /api/auth/me now returns a resolved modules map for every toggleable
module (cycle/coach reflect their delegated state). New PATCH/GET
/api/auth/me/modules read and update the preferences: the body is strict
over the toggleable key set so a core-domain or unknown key is a 422, the
merge is field-by-field (no mass assignment), and userId is narrowed from
the session. Documented in the OpenAPI registry and yaml.

The disable-coach gate-inventory allowlist records the module gate's
intentional coach delegation read.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 20:54:02 +02:00
1 parent d1089ba232
commit fa53631c55
7 files changed
+738 -1

No files matched your search

+85
View File
@@ -10,8 +10,45 @@ import type { ZodOpenApiObject } from "zod-openapi";
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
import { MODULE_KEYS } from "@/lib/modules/registry";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// v1.18.0 — module enable/disable. The PATCH request is the REAL runtime
// validator (`modulePrefsPatchSchema` — strict over the toggleable key
// set; a core-domain or unknown key is a 422). The response is the
// fully-resolved `{ <moduleKey>: boolean }` map for every toggleable
// module: `cycle` mirrors the cycle gate, `coach` mirrors the resolved
// `disableCoach` + operator master flag, the rest read the per-user
// disabled-allowlist. The map is the single thing a client needs to gate
// every secondary domain end-to-end.
const modulePrefsPatchRequest = modulePrefsPatchSchema.meta({
id: "ModulePrefsPatchRequest",
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).',
});
const moduleMapResolved = z
.object(
Object.fromEntries(
MODULE_KEYS.map((k) => [
k,
z
.boolean()
.describe(`Whether the "${k}" module is enabled for this account.`),
]),
),
)
.meta({
id: "ModuleMap",
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.",
});
const moduleMapEnvelopeInner = z
.object({ modules: moduleMapResolved })
.meta({ id: "ModulesResponse" });
// v1.4.49 — single schema for the per-user Coach opt-out flag. Previously
// split into `disableCoachBody` (PATCH request) and `disableCoachData`
// (response payload), both `z.object({ disableCoach: z.boolean() })`. The
@@ -374,6 +411,54 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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: dataEnvelope(
moduleMapEnvelopeInner,
"GetModulesResponse",
),
},
},
},
...stdResponses,
},
},
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: modulePrefsPatchRequest },
},
},
responses: {
"200": {
description: "Resolved next-state module map echoed back.",
content: {
"application/json": {
schema: dataEnvelope(
moduleMapEnvelopeInner,
"PatchModulesResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/source-priority": {
get: {
tags: ["Auth"],