From fa53631c553085ad65eb467d67cd8bd33d666d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 20:54:02 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 169 ++++++++++ .../auth/me/modules/__tests__/route.test.ts | 307 ++++++++++++++++++ src/app/api/auth/me/modules/route.ts | 123 +++++++ src/app/api/auth/me/route.ts | 16 +- .../__tests__/coach-user-disable.test.tsx | 6 + src/lib/openapi/routes/profile.ts | 85 +++++ src/lib/validations/modules.ts | 33 ++ 7 files changed, 738 insertions(+), 1 deletion(-) create mode 100644 src/app/api/auth/me/modules/__tests__/route.test.ts create mode 100644 src/app/api/auth/me/modules/route.ts create mode 100644 src/lib/validations/modules.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0a6360a77..5e957b8d5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3081,6 +3081,50 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/modules: + get: + tags: + - Auth + summary: Read the resolved per-user module enable/disable map + description: "Returns the fully-resolved `{ : 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: + $ref: "#/components/schemas/GetModulesResponse" + "401": *a1 + "422": *a3 + "429": *a4 + 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: + $ref: "#/components/schemas/ModulePrefsPatchRequest" + responses: + "200": + description: Resolved next-state module map echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchModulesResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/me/source-priority: get: tags: @@ -6548,6 +6592,37 @@ components: required: - disableCoach description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + ModulePrefsPatchRequest: + type: object + properties: + cycle: + type: boolean + mood: + type: boolean + sleep: + type: boolean + glucose: + type: boolean + workouts: + type: boolean + recovery: + type: boolean + labs: + type: boolean + achievements: + type: boolean + coach: + type: boolean + insights: + type: boolean + doctorReport: + type: boolean + additionalProperties: false + 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). ProfileUpdateRequest: type: object properties: @@ -12057,6 +12132,100 @@ components: - data - error additionalProperties: false + GetModulesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ModulesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ModulesResponse: + type: object + properties: + modules: + $ref: "#/components/schemas/ModuleMap" + required: + - modules + additionalProperties: false + ModuleMap: + type: object + properties: + cycle: + type: boolean + description: Whether the "cycle" module is enabled for this account. + mood: + type: boolean + description: Whether the "mood" module is enabled for this account. + sleep: + type: boolean + description: Whether the "sleep" module is enabled for this account. + glucose: + type: boolean + description: Whether the "glucose" module is enabled for this account. + workouts: + type: boolean + description: Whether the "workouts" module is enabled for this account. + recovery: + type: boolean + description: Whether the "recovery" module is enabled for this account. + labs: + type: boolean + description: Whether the "labs" module is enabled for this account. + achievements: + type: boolean + description: Whether the "achievements" module is enabled for this account. + coach: + type: boolean + description: Whether the "coach" module is enabled for this account. + insights: + type: boolean + description: Whether the "insights" module is enabled for this account. + doctorReport: + type: boolean + description: Whether the "doctorReport" module is enabled for this account. + required: + - cycle + - mood + - sleep + - glucose + - workouts + - recovery + - labs + - achievements + - coach + - insights + - doctorReport + additionalProperties: false + 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. + PatchModulesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ModulesResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetSourcePriorityResponse: type: object properties: diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts new file mode 100644 index 000000000..d7d80ce70 --- /dev/null +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -0,0 +1,307 @@ +/** + * v1.18.0 — PATCH/GET /api/auth/me/modules. + * + * Covers the read projection, the field-by-field merge, core-module + * refusal (strict schema), the cycle/coach delegation reflected in the + * resolved map, and the standard auth / rate-limit / 422 envelope. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + cycleProfile: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); + +// Operator master flag = on by default; coach delegation keys off it. +// Partial mock — api-handler imports `AssistantDisabledError` from here. +vi.mock("@/lib/feature-flags", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAssistantFlags: vi.fn().mockResolvedValue({ coach: true }), + }; +}); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getAssistantFlags } from "@/lib/feature-flags"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +type ModuleMap = Record; + +function mkPatch(body: unknown): Request { + return new Request("http://localhost/api/auth/me/modules", { + method: "PATCH", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +/** + * Default DB shape: fresh user, no module prefs, no cycle row. The + * `findUnique` mock reads from a mutable holder so a PATCH `update` + * (which writes `modulePreferencesJson`) is reflected by the post-update + * `resolveModuleMap` re-read — mirroring real DB round-trip semantics. + */ +function primeUser(over: { + gender?: string | null; + disableCoach?: boolean; + modulePreferencesJson?: unknown; + cycleTrackingEnabled?: boolean | null; +} = {}) { + const row: { + gender: string | null; + disableCoach: boolean; + modulePreferencesJson: unknown; + } = { + gender: over.gender ?? null, + disableCoach: over.disableCoach ?? false, + modulePreferencesJson: over.modulePreferencesJson ?? null, + }; + vi.mocked(prisma.user.findUnique).mockImplementation( + (() => Promise.resolve({ ...row })) as never, + ); + vi.mocked(prisma.user.update).mockImplementation(((args: { + data?: { modulePreferencesJson?: unknown }; + }) => { + const data = args.data; + if (data && "modulePreferencesJson" in data) { + row.modulePreferencesJson = data.modulePreferencesJson; + } + return Promise.resolve({}); + }) as never); + vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue( + over.cycleTrackingEnabled === undefined + ? null + : ({ cycleTrackingEnabled: over.cycleTrackingEnabled } as never), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAssistantFlags).mockResolvedValue({ coach: true } as never); +}); + +describe("GET /api/auth/me/modules", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + expect(res.status).toBe(401); + }); + + it("returns every toggleable module on for a fresh user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.mood).toBe(true); + expect(env.data.modules.sleep).toBe(true); + expect(env.data.modules.insights).toBe(true); + // cycle gender-derived (null gender ⇒ off); coach delegated (on). + expect(env.data.modules.cycle).toBe(false); + expect(env.data.modules.coach).toBe(true); + }); + + it("reflects a disabled module from the persisted allowlist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: { glucose: false } }); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.glucose).toBe(false); + expect(env.data.modules.mood).toBe(true); + }); + + it("reflects coach delegation (operator master flag off)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(getAssistantFlags).mockResolvedValue({ coach: false } as never); + primeUser(); + const res = await (GET as (r: Request) => Promise)( + new Request("http://localhost/api/auth/me/modules"), + ); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.coach).toBe(false); + }); +}); + +describe("PATCH /api/auth/me/modules", () => { + it("rejects an unauthenticated request with 401", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: false }), + ); + expect(res.status).toBe(401); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("disables a module and persists the merged allowlist", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: null }); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: false }), + ); + expect(res.status).toBe(200); + const env = (await res.json()) as { data: { modules: ModuleMap } }; + expect(env.data.modules.mood).toBe(false); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { mood: false } }, + }); + expect(auditLog).toHaveBeenCalledWith( + "user.modules.update", + expect.objectContaining({ + userId: "user-1", + details: expect.objectContaining({ changed: ["mood"] }), + }), + ); + }); + + it("merges over the existing allowlist field-by-field (siblings intact)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // user previously disabled glucose; now disabling sleep too. + primeUser({ modulePreferencesJson: { glucose: false } }); + + await (PATCH as (r: Request) => Promise)(mkPatch({ sleep: false })); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { glucose: false, sleep: false } }, + }); + }); + + it("can re-enable a previously disabled module", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser({ modulePreferencesJson: { glucose: false } }); + + await (PATCH as (r: Request) => Promise)(mkPatch({ glucose: true })); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { glucose: true } }, + }); + }); + + it("REFUSES to disable a core module (strict schema 422)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + + for (const core of ["weight", "bloodPressure", "pulse", "medications"]) { + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ [core]: false }), + ); + expect(res.status).toBe(422); + } + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects an unknown module key with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ notAModule: false }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects a non-boolean value with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + primeUser(); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: "false" }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects malformed JSON with 422", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const req = new Request("http://localhost/api/auth/me/modules", { + method: "PATCH", + body: "{ not valid json", + headers: { "Content-Type": "application/json" }, + }); + const res = await (PATCH as (r: Request) => Promise)(req); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("returns 429 when the per-user rate-limit fires", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 30_000, + }); + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ mood: false }), + ); + expect(res.status).toBe(429); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("drops a previously-corrupted stored blob to clean booleans on merge", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // junk in the stored row must not leak into the persisted merge. + primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } }); + + await (PATCH as (r: Request) => Promise)(mkPatch({ labs: false })); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { modulePreferencesJson: { sleep: false, labs: false } }, + }); + }); +}); diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts new file mode 100644 index 000000000..2be6c5b5d --- /dev/null +++ b/src/app/api/auth/me/modules/route.ts @@ -0,0 +1,123 @@ +/** + * v1.18.0 — per-user module enable/disable preferences. + * + * GET /api/auth/me/modules — resolved `{ : boolean }` map + * for every toggleable module (cycle/coach + * reflect their real delegated state). + * PATCH /api/auth/me/modules — body `{ ?: boolean }`. Merges + * the supplied keys into the persisted + * `modulePreferencesJson` DISABLED allowlist + * (field-by-field, no mass assignment) and + * 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. + * + * `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 { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { resolveModuleMap, normalisePrefs } from "@/lib/modules/gate"; +import { modulePrefsPatchSchema } from "@/lib/validations/modules"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.modules.get" } }); + + const modules = await resolveModuleMap(user.id); + return apiSuccess({ modules }); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `modules:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "modules.body.invalid_json"); + } + + const parsed = modulePrefsPatchSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.modules.patch.invalid_shape" }, + meta: { issues: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "modules.invalid", + }); + } + + // Merge the supplied keys into the persisted DISABLED allowlist, + // field-by-field. `normalisePrefs` strips any junk from the stored + // row so a previously-corrupted blob can't poison the merge, and the + // strict schema guarantees `parsed.data` carries only toggleable keys + // (core domains were rejected at the 422 above). + const existing = await prisma.user.findUnique({ + where: { id: user.id }, + select: { modulePreferencesJson: true }, + }); + const merged: Record = normalisePrefs( + existing?.modulePreferencesJson, + ); + const changed: string[] = []; + for (const [key, value] of Object.entries(parsed.data)) { + if (value === undefined) continue; + merged[key] = value; + changed.push(key); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: merged }, + }); + + const modules = await resolveModuleMap(user.id); + + await auditLog("user.modules.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { changed }, + }); + + annotate({ + action: { name: "auth.me.modules.patch" }, + meta: { changed }, + }); + + return apiSuccess({ modules }); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 6538c5818..6da40db69 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -6,6 +6,7 @@ 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"; export const dynamic = "force-dynamic"; @@ -21,12 +22,18 @@ 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] = await Promise.all([ + const [, cycleProfile, modules] = await Promise.all([ setOnboardingPendingCookie(user.onboardingCompletedAt == null), prisma.cycleProfile.findUnique({ where: { userId: user.id }, select: { cycleTrackingEnabled: true }, }), + // v1.18.0 — resolved module enable/disable map for every toggleable + // module. cycle/coach reflect their real delegated state (the cycle + // gate / disableCoach + operator assistant flag); the rest read the + // disabled-allowlist `modulePreferencesJson`. Default-on. Clients + // hide a whole module surface end-to-end when its key is `false`. + resolveModuleMap(user.id), ]); const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); @@ -85,5 +92,12 @@ export const GET = apiHandler(async () => { // v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS // hides the whole cycle tab when this is false. cycleTrackingEnabled, + // v1.18.0 — module enable/disable map. `{ : boolean }` + // for every toggleable module; `false` means the module is OFF and + // the surface should disappear end-to-end (nav, dashboard, insights, + // …). `cycle` mirrors `cycleTrackingEnabled` and `coach` mirrors the + // resolved `disableCoach` + operator master flag, so this map is the + // single thing a client needs to gate every secondary domain. + modules, }); }); diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index caedd7d9e..902aa2783 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -317,6 +317,12 @@ describe("Coach per-user disableCoach invariant", () => { // builder. "src/lib/dashboard/snapshot.ts", "src/app/api/dashboard/snapshot/route.ts", + // v1.18.0 — the module enable/disable gate delegates the `coach` + // module to the SAME two-layer source of truth (`user.disableCoach` + // AND the operator assistant master flag) rather than owning a + // second copy in `modulePreferencesJson`. Covered by the coach- + // delegation cases in `src/lib/modules/__tests__/gate.test.ts`. + "src/lib/modules/gate.ts", ]; /** diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 3a6f4224c..1c200012e 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -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 `{ : 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 = { }, }, }, + "/api/auth/me/modules": { + get: { + tags: ["Auth"], + summary: "Read the resolved per-user module enable/disable map", + description: + "Returns the fully-resolved `{ : 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"], diff --git a/src/lib/validations/modules.ts b/src/lib/validations/modules.ts new file mode 100644 index 000000000..44c29ebf6 --- /dev/null +++ b/src/lib/validations/modules.ts @@ -0,0 +1,33 @@ +/** + * v1.18.0 — module enable/disable request validation. + * + * The PATCH body is a partial map of TOGGLEABLE module keys → boolean. + * Only keys in the canonical registry (`MODULE_KEYS`) are accepted; a + * core-domain key (`weight`, `bloodPressure`, `pulse`, `medications`) or + * any unknown key is a 422 — so the core measurement engine + meds can + * never be disabled through this surface (defence-in-depth on top of the + * gate, which has no key to flip for core domains anyway). + * + * `strict()` rejects unknown keys outright rather than silently dropping + * them, so a client gets a clear error instead of a no-op. + * + * The shared shape is reused by the OpenAPI registry so the wire + * contract stays single-source. + */ +import { z } from "zod/v4"; + +import { MODULE_KEYS } from "@/lib/modules/registry"; + +/** Build `{ ?: boolean }` from the canonical key list. */ +const moduleShape = Object.fromEntries( + MODULE_KEYS.map((k) => [k, z.boolean().optional()]), +) as Record<(typeof MODULE_KEYS)[number], z.ZodOptional>; + +/** + * Partial module-preferences PATCH body. Every key is an optional + * boolean; omitted keys are left untouched server-side. `strict()` + * rejects any key not in `MODULE_KEYS` (including core domains). + */ +export const modulePrefsPatchSchema = z.object(moduleShape).strict(); + +export type ModulePrefsPatch = z.infer;