mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
This commit is contained in:
1 parent
3264dd2e86
commit
70402ea95a
10 files changed
+361
-5
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user