merge(modules): gate achievements behind the achievements module flag

# Conflicts:
#	src/hooks/use-auth.ts
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 21:57:43 +02:00
commit 6bde45ce0a
10 files changed
+349 -7

No files matched your search

+20 -3
View File
@@ -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 `<RecentAchievementsCard>` and
// `<AchievementUnlockNotifier>` 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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
@@ -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 (
<div className="space-y-6">
@@ -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", () => ({
@@ -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", () => ({
@@ -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();
});
});
@@ -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({
@@ -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 — `<AchievementUnlockNotifier>` 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<string, boolean> } | 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(
<I18nProvider initialLocale="en">
<AchievementUnlockNotifier userId="user-1" />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockUser = {};
});
describe("<AchievementUnlockNotifier> — 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 }),
);
});
});
@@ -14,8 +14,10 @@ import type { AchievementProgress } from "@/lib/gamification/achievements";
* with their localized title + completedAt date.
*/
let mockUser: { modules?: Record<string, boolean> } | 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("<RecentAchievementsCard> — 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("<RecentAchievementsCard>", () => {
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
@@ -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<Set<string>>(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,
});
@@ -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 (
<div
data-slot="recent-achievements-card"
+4 -2
View File
@@ -90,9 +90,11 @@ export interface AuthUser {
* opt-out) and already reflected here, so nav + Insights pill gates read
* this map rather than re-deriving. Coerced to `{}` against a stale /me
* payload (older server image without the field) so every gate fails open
* — a missing map never blanks the nav.
* — a missing map never blanks the nav. Optional so existing `AuthUser`
* test fixtures stay valid; `fetchMe` always populates it (empty map when
* absent), so live code reads a real map.
*/
modules: Partial<Record<ModuleKey, boolean>>;
modules?: Partial<Record<ModuleKey, boolean>>;
}
async function fetchMe(): Promise<AuthUser> {