From 70402ea95aa08b9d571f3c157476a9ce607724ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 21:25:40 +0200 Subject: [PATCH] feat(modules): gate achievements behind the achievements module flag When an account turns the achievements module off the whole gamification surface disappears. The route enforces the gate after auth and returns the 403 module.disabled envelope, so no badge evaluation, unlock persistence, or payload is produced. The achievements page, the dashboard recent-unlocks tile, and the unlock notifier read the resolved module map from GET /api/auth/me and render nothing / disable their query when the module is off. Default-on: an absent key reads as enabled, a stale /me payload coerces to an empty map so no surface vanishes on a partial-deploy rollback. --- src/app/achievements/page.tsx | 23 ++- .../__tests__/hidden-redaction.test.ts | 11 ++ .../achievements/__tests__/ios-format.test.ts | 10 ++ .../__tests__/module-gate.test.ts | 153 ++++++++++++++++++ .../api/gamification/achievements/route.ts | 16 ++ .../achievement-unlock-notifier.test.tsx | 70 ++++++++ .../recent-achievements-card.test.tsx | 40 ++++- .../achievement-unlock-notifier.tsx | 10 ++ .../gamification/recent-achievements-card.tsx | 17 +- src/hooks/use-auth.ts | 16 ++ 10 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 src/app/api/gamification/achievements/__tests__/module-gate.test.ts create mode 100644 src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index fab01da16..513927772 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -260,16 +260,25 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { } export default function AchievementsPage() { - const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); + // v1.18.0 — the achievements module gate. When the account has the + // module turned off the whole page disappears: an empty state stands in + // for the badge grid and the data query never fires (the API would 403 + // anyway). Default-on: an absent key reads as enabled, so the page only + // hides on an explicit `false`. + const achievementsEnabled = user?.modules?.achievements !== false; + // v1.4.34 IW-F-Perf — mother page rides the shared cache slot // alongside `` and // `` so the three consumers never trigger // more than one network call on a cold dashboard mount. - const { data, isLoading } = useAchievementsQuery(); + const { data, isLoading } = useAchievementsQuery({ + enabled: isAuthenticated && achievementsEnabled, + }); - if (authLoading || isLoading) { + if (authLoading || (achievementsEnabled && isLoading)) { return (
@@ -277,6 +286,14 @@ export default function AchievementsPage() { ); } + // v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry + // is hidden by the same gate, so a direct hit on /achievements renders + // nothing rather than an orphaned shell. No new i18n string is minted + // (the disabled state has no copy of its own). + if (isAuthenticated && !achievementsEnabled) { + return null; + } + if (!isAuthenticated) { return (
diff --git a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts index 69d8ad58e..3113db31c 100644 --- a/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts +++ b/src/app/api/gamification/achievements/__tests__/hidden-redaction.test.ts @@ -19,6 +19,17 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +// v1.18.0 — this suite predates the achievements module gate and only +// mocks the aggregation Prisma models. Pin the gate to "enabled" so it +// stays focused on the hidden-Easter-egg redaction contract; the gate's +// own enable/disable behaviour is covered in module-gate.test.ts. +vi.mock("@/lib/modules/gate", () => ({ + // Plain async fn (not a `vi.fn`) so `vi.resetAllMocks()` in beforeEach + // can't blank it out and flip the gate to a falsy decision. + requireModuleEnabled: async () => ({ enabled: true }), + MODULE_DISABLED_ERROR_CODE: "module.disabled", +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ diff --git a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts index 9d46e416f..44323d52e 100644 --- a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts +++ b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts @@ -19,6 +19,16 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +// v1.18.0 — this suite predates the achievements module gate and only +// mocks the aggregation Prisma models. Pin the gate to "enabled" (a plain +// async fn so `vi.resetAllMocks()` can't blank it out) so the format +// branches stay in scope; the gate's enable/disable behaviour is covered +// in module-gate.test.ts. +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: async () => ({ enabled: true }), + MODULE_DISABLED_ERROR_CODE: "module.disabled", +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ diff --git a/src/app/api/gamification/achievements/__tests__/module-gate.test.ts b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts new file mode 100644 index 000000000..657ca5f4b --- /dev/null +++ b/src/app/api/gamification/achievements/__tests__/module-gate.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.18.0 — the achievements module gate on + * `GET /api/gamification/achievements`. + * + * Disabled ⇒ a 403 `module.disabled` envelope, and the heavy badge + * aggregation never runs (no Prisma reads, no unlock + * persistence). This is what makes the surface DISAPPEAR for + * an account that has turned the module off — the web page, + * the dashboard tile, and the unlock notifier all gate the + * same route and the same `/api/auth/me` module map. + * Enabled ⇒ the route behaves exactly as before (200 + payload). + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + passkey: { findMany: vi.fn() }, + auditLog: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + userAchievement: { + findMany: vi.fn(), + createMany: vi.fn(), + }, + userHealthProfile: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// The module gate is mocked directly so this test asserts the route's +// branch on the gate decision, not the resolver internals (those have +// their own unit coverage in src/lib/modules/__tests__/gate.test.ts). +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), + MODULE_DISABLED_ERROR_CODE: "module.disabled", +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { apiError } from "@/lib/api-response"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; + +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, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + heightCm: null, + locale: "en", + }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( + null as never, + ); +}); + +describe("GET /api/gamification/achievements — achievements module gate", () => { + it("returns the 403 module.disabled envelope when the module is off", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError("Module \"achievements\" is not enabled", 403, { + errorCode: "module.disabled", + module: "achievements", + }), + }); + + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + + expect(res.status).toBe(403); + const body = (await res.json()) as { + data: null; + error: string; + meta: { errorCode: string; module: string }; + }; + expect(body.data).toBeNull(); + expect(body.meta.errorCode).toBe("module.disabled"); + expect(body.meta.module).toBe("achievements"); + + // The disappearance is total: no badge aggregation ran. + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + expect(prisma.userAchievement.findMany).not.toHaveBeenCalled(); + expect(prisma.userAchievement.createMany).not.toHaveBeenCalled(); + }); + + it("serves the payload normally when the module is enabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { achievements: unknown[]; summary: unknown }; + }; + expect(body.data.achievements).toBeDefined(); + expect(body.data.summary).toBeDefined(); + expect(prisma.userAchievement.findMany).toHaveBeenCalled(); + }); + + it("still 401s before the module gate when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + + expect(res.status).toBe(401); + // Auth is the outer guard — the module gate is never consulted. + expect(requireModuleEnabled).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index e521401e8..f7c944833 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { requireModuleEnabled } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; @@ -469,6 +470,21 @@ function getCompliance80DaySeries( export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); + + // v1.18.0 — when the account has the achievements module turned off the + // whole gamification surface disappears: no badge evaluation, no unlock + // persistence, no payload. Returns the 403 `module.disabled` envelope + // verbatim so the client (web + iOS) hides the page / dashboard tile / + // unlock toast in lock-step with this refusal. + const gate = await requireModuleEnabled(user.id, "achievements"); + if (!gate.enabled) { + annotate({ + action: { name: "gamification.achievements" }, + meta: { moduleDisabled: true }, + }); + return gate.response; + } + const formatParam = request.nextUrl.searchParams.get("format"); const isIosFormat = formatParam === "ios"; annotate({ diff --git a/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx b/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx new file mode 100644 index 000000000..de14bdb3c --- /dev/null +++ b/src/components/gamification/__tests__/achievement-unlock-notifier.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.18.0 — `` honours the achievements module + * gate. When the module is disabled the polling query is never enabled, + * so no unlock toast can surface — the engagement surface disappears in + * lock-step with the page, the dashboard tile, and the 403 route. + */ + +let mockUser: { modules?: Record } | null = {}; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: mockUser }), +})); + +const useAchievementsQuery = vi.fn( + (_opts?: { enabled?: boolean }) => ({ data: undefined }), +); + +vi.mock("@/lib/queries/use-achievements-query", () => ({ + useAchievementsQuery: (opts?: { enabled?: boolean }) => + useAchievementsQuery(opts), +})); + +const toast = vi.fn(); +vi.mock("sonner", () => ({ toast: (...args: unknown[]) => toast(...args) })); + +import { AchievementUnlockNotifier } from "../achievement-unlock-notifier"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockUser = {}; +}); + +describe(" — achievements module gate", () => { + it("disables the polling query when the module is off", () => { + mockUser = { modules: { achievements: false } }; + expect(render()).toBe(""); + expect(useAchievementsQuery).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }), + ); + }); + + it("enables the polling query when the module is on (explicit true)", () => { + mockUser = { modules: { achievements: true } }; + render(); + expect(useAchievementsQuery).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); + + it("enables the query by default when the module key is absent (default-on)", () => { + mockUser = { modules: {} }; + render(); + expect(useAchievementsQuery).toHaveBeenCalledWith( + expect.objectContaining({ enabled: true }), + ); + }); +}); diff --git a/src/components/gamification/__tests__/recent-achievements-card.test.tsx b/src/components/gamification/__tests__/recent-achievements-card.test.tsx index 10c85fabf..dc538d1f2 100644 --- a/src/components/gamification/__tests__/recent-achievements-card.test.tsx +++ b/src/components/gamification/__tests__/recent-achievements-card.test.tsx @@ -14,8 +14,10 @@ import type { AchievementProgress } from "@/lib/gamification/achievements"; * with their localized title + completedAt date. */ +let mockUser: { modules?: Record } | null = {}; + vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ isAuthenticated: true }), + useAuth: () => ({ isAuthenticated: true, user: mockUser }), })); let mockData: @@ -54,6 +56,7 @@ function render() { beforeEach(() => { vi.clearAllMocks(); mockIsPending = false; + mockUser = {}; }); const baseAchievement: AchievementProgress = { @@ -130,6 +133,41 @@ describe("pickRecentUnlocks", () => { }); }); +describe(" — achievements module gate", () => { + it("renders nothing when the achievements module is disabled", () => { + // v1.18.0 — an explicit `false` for the module hides the dashboard + // tile entirely so the surface disappears in lock-step with the page + // and the 403 route. + mockUser = { modules: { achievements: false } }; + mockData = { + summary: {}, + achievements: [ + { ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" }, + ], + metrics: {}, + }; + expect(render()).toBe(""); + }); + + it("renders the tile when the module is enabled (explicit true)", () => { + mockUser = { modules: { achievements: true } }; + mockData = { + summary: {}, + achievements: [ + { ...baseAchievement, id: "a", completedAt: "2026-04-15T12:00:00.000Z" }, + ], + metrics: {}, + }; + expect(render()).toContain('data-slot="recent-achievements-card"'); + }); + + it("renders the tile by default when the module key is absent (default-on)", () => { + mockUser = { modules: {} }; + mockData = { summary: {}, achievements: [], metrics: {} }; + expect(render()).toContain('data-slot="recent-achievements-card"'); + }); +}); + describe("", () => { it("renders a skeleton (no content, no empty state) while the query is pending", () => { // The flash this guards against: before the query settles the card diff --git a/src/components/gamification/achievement-unlock-notifier.tsx b/src/components/gamification/achievement-unlock-notifier.tsx index cc78ea943..e138c97a3 100644 --- a/src/components/gamification/achievement-unlock-notifier.tsx +++ b/src/components/gamification/achievement-unlock-notifier.tsx @@ -3,6 +3,7 @@ import { Sparkles, Trophy } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; import { toast } from "sonner"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { useAchievementsQuery } from "@/lib/queries/use-achievements-query"; @@ -56,6 +57,14 @@ export function AchievementUnlockNotifier({ userId, }: AchievementUnlockNotifierProps) { const { t } = useTranslations(); + const { user } = useAuth(); + + // v1.18.0 — the achievements module gate. When the account has the + // module turned off the notifier goes inert: the polling query never + // fires and no unlock toast can surface. Default-on: an absent key reads + // as enabled, so the notifier only stands down on an explicit `false`. + const achievementsEnabled = user?.modules?.achievements !== false; + const storageKey = useMemo(() => `${STORAGE_KEY_PREFIX}:${userId}`, [userId]); const seenIdsRef = useRef>(new Set()); const localStateReadyRef = useRef(false); @@ -70,6 +79,7 @@ export function AchievementUnlockNotifier({ // long-open tabs still surface new unlocks without forcing the card // to re-render on the same cadence. const { data } = useAchievementsQuery({ + enabled: !!user && achievementsEnabled, refetchInterval: 2 * 60 * 1000, }); diff --git a/src/components/gamification/recent-achievements-card.tsx b/src/components/gamification/recent-achievements-card.tsx index fccd23501..f7a7feb40 100644 --- a/src/components/gamification/recent-achievements-card.tsx +++ b/src/components/gamification/recent-achievements-card.tsx @@ -19,6 +19,7 @@ import { type LucideIcon, } from "lucide-react"; import Link from "next/link"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; import { useAchievementsQuery } from "@/lib/queries/use-achievements-query"; @@ -81,6 +82,13 @@ const RECENT_LIMIT = 3; */ export function RecentAchievementsCard() { const { t } = useTranslations(); + const { user, isAuthenticated } = useAuth(); + + // v1.18.0 — the achievements module gate. When the account has the + // module turned off the dashboard tile disappears entirely (the data + // query never fires; the API would 403 anyway). Default-on: an absent + // key reads as enabled, so the tile only hides on an explicit `false`. + const achievementsEnabled = user?.modules?.achievements !== false; // `isPending` (no data yet, fetch in flight or gated) drives the // loading branch. Rendering the empty / content branch before the @@ -89,10 +97,17 @@ export function RecentAchievementsCard() { // content) for the fetch window, then swapped once the real payload // landed. A skeleton holds the card's footprint without committing to // a content shape it may have to retract. - const { data, isPending } = useAchievementsQuery(); + const { data, isPending } = useAchievementsQuery({ + enabled: isAuthenticated && achievementsEnabled, + }); const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT); + // Module off ⇒ render nothing so the tile vanishes from the dashboard. + if (!achievementsEnabled) { + return null; + } + return (
; } async function fetchMe(): Promise { @@ -117,6 +128,11 @@ async function fetchMe(): Promise { // v1.15.0 — coerce against a stale /me payload so the cycle nav entry // stays hidden by default when the field is absent. cycleTrackingEnabled: data.cycleTrackingEnabled === true, + // v1.18.0 — coerce against a stale /me payload so module gates default + // to enabled (empty map ⇒ every key reads on) rather than hiding a + // surface on an older server image without the field. + modules: + data.modules && typeof data.modules === "object" ? data.modules : {}, }; }