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 : {}, }; }