mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
merge(modules): gate achievements behind the achievements module flag
# Conflicts: # src/hooks/use-auth.ts
This commit is contained in:
commit
6bde45ce0a
10 files changed
+349
-7
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user