From f435142db5af49bbdb0a860cedc72829b579be91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 00:35:59 +0200 Subject: [PATCH] feat(profile): carry the resolved module map on the /api/user/profile alias The native client reads /api/user/profile, not /api/auth/me, so the v1.18.0 module enable/disable map never reached it. Resolve the same `resolveModuleMap` projection /api/auth/me returns and include it on the alias response, overlapped with the profile-row read. Extend the route test to assert the map is present and server-authoritative. --- .../api/user/profile/__tests__/route.test.ts | 51 ++++++++++++++++++ src/app/api/user/profile/route.ts | 53 ++++++++++++------- 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index 67b6568f7..8a8654e13 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -19,6 +19,18 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +// v1.18.0 — the GET alias resolves the module map. Stub it so these +// tests assert the projection wiring without standing up the gate's +// own DB round-trips (operator availability + assistant flags + cycle). +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: vi.fn().mockResolvedValue({ + sleep: true, + glucose: true, + cycle: false, + coach: true, + }), +})); + vi.mock("next/headers", () => ({ headers: vi.fn(async () => ({ get: () => null })), cookies: vi.fn(async () => ({ @@ -31,6 +43,14 @@ vi.mock("next/headers", () => ({ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { resolveModuleMap } from "@/lib/modules/gate"; + +const MODULE_MAP = { + sleep: true, + glucose: true, + cycle: false, + coach: true, +} as const; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -39,6 +59,7 @@ const SESSION_OK = { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -108,6 +129,36 @@ describe("GET /api/user/profile", () => { expect(body.data.insurerIkNumber).toBe("101234567"); expect(body.data.insurerName).toBe("Example Insurer"); }); + + // v1.18.0 — the alias the iOS app reads must carry the resolved module + // map, matching the /api/auth/me projection so the client gets module + // flags from the endpoint it actually fetches. + it("includes the resolved module map", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + username: "testuser", + displayName: null, + email: null, + dateOfBirth: null, + gender: null, + heightCm: null, + locale: null, + timezone: "Europe/Berlin", + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumberEncrypted: null, + } as never); + + const res = await callGet(makeGetReq()); + expect(res.status).toBe(200); + expect(resolveModuleMap).toHaveBeenCalledWith("user-1"); + const body = (await res.json()) as { + data: { modules: Record }; + }; + expect(body.data.modules).toEqual(MODULE_MAP); + }); }); describe("PATCH /api/user/profile", () => { diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index cf799b456..095ade7e3 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context"; import { applyProfileUpdate } from "@/lib/auth/profile-update"; import { prisma } from "@/lib/db"; import { decrypt } from "@/lib/crypto"; +import { resolveModuleMap } from "@/lib/modules/gate"; export const dynamic = "force-dynamic"; @@ -25,25 +26,35 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "user.profile.get" } }); - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { - username: true, - displayName: true, - email: true, - dateOfBirth: true, - gender: true, - heightCm: true, - locale: true, - timezone: true, - timeFormat: true, - moodReminderEnabled: true, - fullName: true, - insurerName: true, - insurerIkNumber: true, - insuranceNumberEncrypted: true, - }, - }); + // Overlap the profile-row read with the module-map resolve (a separate + // memoised Postgres round-trip) — both feed the same response and have + // no ordering dependency. + const [dbUser, modules] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { + username: true, + displayName: true, + email: true, + dateOfBirth: true, + gender: true, + heightCm: true, + locale: true, + timezone: true, + timeFormat: true, + moodReminderEnabled: true, + fullName: true, + insurerName: true, + insurerIkNumber: true, + insuranceNumberEncrypted: true, + }, + }), + // v1.18.0 — resolved module enable/disable map, identical to the + // projection `GET /api/auth/me` returns. The iOS app reads this alias + // endpoint, so it must carry the same server-authoritative flags; a + // `false` key means the module surface disappears end-to-end. + resolveModuleMap(user.id), + ]); // v1.7.0 — decrypt the KVNR fail-soft so a key-rotation gap never 500s // the profile fetch. @@ -71,6 +82,10 @@ export const GET = apiHandler(async () => { insurerName: dbUser?.insurerName ?? null, insurerIkNumber: dbUser?.insurerIkNumber ?? null, insuranceNumber, + // v1.18.0 — module enable/disable map, matching the /api/auth/me + // projection exactly so the native client gets module flags from the + // endpoint it actually reads. + modules, }); });