From 848914caa67c31ec84cdf106571a94ff7a515cc7 Mon Sep 17 00:00:00 2001 From: Francis Ifechukwu Date: Mon, 24 Aug 2026 16:00:30 +0100 Subject: [PATCH 1/2] feat(admin): add platform health dashboard --- apps/web/app/admin/page.tsx | 113 +++++++++++++++++- .../admin/dashboard/__tests__/route.test.ts | 73 +++++++++++ apps/web/app/api/admin/dashboard/route.ts | 43 +++++++ 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 apps/web/app/api/admin/dashboard/__tests__/route.test.ts create mode 100644 apps/web/app/api/admin/dashboard/route.ts diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index 840f2431a..708fd707e 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -10,6 +10,9 @@ import { Sparkles, Shield, ShieldAlert, + Users, + Activity, + ClipboardList, } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; @@ -22,6 +25,14 @@ import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { getAllHuntsIncludingPrivate, setLocalFeaturedHunt } from "@/lib/huntStore"; import type { StoredHunt } from "@/lib/types"; +type DashboardHealth = { + hunts: { total: number; active: number; pendingReview: number }; + players: { registrations: number }; + moderation: { pending: number }; + api: { errorRate: number; sampledRequests: number }; + generatedAt: string; +}; + function StatusBadge({ status }: { status: StoredHunt["status"] }) { const config: Partial> = { Draft: @@ -45,10 +56,52 @@ function StatusBadge({ status }: { status: StoredHunt["status"] }) { ); } +function HealthCard({ + icon: Icon, + label, + value, + detail, + href, +}: { + icon: typeof Trophy; + label: string; + value: string | number; + detail: string; + href?: string; +}) { + const content = ( +
+
+ {label} + +
+

{value}

+

{detail}

+
+ ); + return href ? ( + + {content} + + ) : ( + content + ); +} + export default function AdminPage() { const [hunts, setHunts] = useState([]); const [filter, setFilter] = useState<"all" | "Active" | "Completed" | "Draft">("all"); + const { data: health, isLoading: isHealthLoading } = useQuery({ + queryKey: ["adminDashboardHealth"], + queryFn: async () => { + const res = await fetch("/api/admin/dashboard"); + if (!res.ok) throw new Error("Failed to load platform health"); + return res.json() as Promise; + }, + refetchInterval: 60_000, + }); + // Fetch featured hunt ID from server API const { data: featuredData, refetch: refetchFeatured } = useQuery({ queryKey: ["featuredHuntId"], @@ -226,6 +279,65 @@ export default function AdminPage() { +
+
+
+

+ Platform health +

+

+ Live operational overview across the platform. +

+
+ {health && ( +

+ Updated {new Date(health.generatedAt).toLocaleTimeString()} +

+ )} +
+
+ + + + +
+
+ {/* Active curation overview */} {featuredHunt ? (
@@ -393,4 +505,3 @@ export default function AdminPage() { ); } - \ No newline at end of file diff --git a/apps/web/app/api/admin/dashboard/__tests__/route.test.ts b/apps/web/app/api/admin/dashboard/__tests__/route.test.ts new file mode 100644 index 000000000..3a2906eca --- /dev/null +++ b/apps/web/app/api/admin/dashboard/__tests__/route.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getAllHunts = vi.fn(); +const getPendingSubmissions = vi.fn(); +const getErrorRate = vi.fn(); +const getMetrics = vi.fn(); + +vi.mock("@/lib/huntStore", () => ({ getAllHunts })); +vi.mock("@/lib/moderation/dbStore", () => ({ getPendingSubmissions })); +vi.mock("@/lib/monitoring/apiMonitor", () => ({ getErrorRate, getMetrics })); +vi.mock("@sentry/nextjs", () => ({ captureEvent: vi.fn(), captureException: vi.fn() })); + +async function loadRoute() { + vi.resetModules(); + return import("../route"); +} + +function request(token?: string) { + return new Request("http://localhost/api/admin/dashboard", { + headers: token ? { authorization: `Bearer ${token}` } : undefined, + }); +} + +describe("GET /api/admin/dashboard", () => { + const originalSecret = process.env.ADMIN_API_SECRET; + + beforeEach(() => { + process.env.ADMIN_API_SECRET = "admin-secret"; + getAllHunts.mockReturnValue([ + { status: "Active", playerCount: 3 }, + { status: "PendingReview", playerCount: 2 }, + { status: "Completed", playerCount: 1 }, + ]); + getPendingSubmissions.mockResolvedValue([{ id: "pending-1" }]); + getErrorRate.mockReturnValue(0.025); + getMetrics.mockReturnValue([{}, {}, {}]); + }); + + afterEach(() => { + if (originalSecret === undefined) delete process.env.ADMIN_API_SECRET; + else process.env.ADMIN_API_SECRET = originalSecret; + vi.clearAllMocks(); + }); + + it("rejects requests without an admin bearer token", async () => { + const { GET } = await loadRoute(); + const response = await GET(request() as never, undefined); + + expect(response.status).toBe(401); + expect(getAllHunts).not.toHaveBeenCalled(); + }); + + it("rejects requests with an invalid admin bearer token", async () => { + const { GET } = await loadRoute(); + const response = await GET(request("wrong-secret") as never, undefined); + + expect(response.status).toBe(401); + expect(getPendingSubmissions).not.toHaveBeenCalled(); + }); + + it("returns platform health metrics for an authorized admin", async () => { + const { GET } = await loadRoute(); + const response = await GET(request("admin-secret") as never, undefined); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + hunts: { total: 3, active: 1, pendingReview: 1 }, + players: { registrations: 6 }, + moderation: { pending: 1 }, + api: { errorRate: 0.025, sampledRequests: 3 }, + }); + }); +}); diff --git a/apps/web/app/api/admin/dashboard/route.ts b/apps/web/app/api/admin/dashboard/route.ts new file mode 100644 index 000000000..3a43b7534 --- /dev/null +++ b/apps/web/app/api/admin/dashboard/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; + +import { assertAdminAuth } from "@/lib/api/adminAuth"; +import { withErrorHandling } from "@/lib/api/withErrorHandling"; +import { getAllHunts } from "@/lib/huntStore"; +import { getPendingSubmissions } from "@/lib/moderation/dbStore"; +import { getErrorRate, getMetrics } from "@/lib/monitoring/apiMonitor"; + +/** + * GET /api/admin/dashboard + * + * Returns the platform-wide counters displayed by the admin overview. This + * endpoint intentionally uses the same admin bearer-token guard as the other + * admin APIs because the aggregate data is not public operational telemetry. + */ +export const GET = withErrorHandling(async (req: Request) => { + assertAdminAuth(req); + + const [hunts, pendingSubmissions] = await Promise.all([ + Promise.resolve(getAllHunts()), + getPendingSubmissions(), + ]); + const metrics = getMetrics(); + + return NextResponse.json({ + hunts: { + total: hunts.length, + active: hunts.filter((hunt) => hunt.status === "Active").length, + pendingReview: hunts.filter((hunt) => hunt.status === "PendingReview").length, + }, + players: { + registrations: hunts.reduce((total, hunt) => total + (hunt.playerCount ?? 0), 0), + }, + moderation: { + pending: pendingSubmissions.length, + }, + api: { + errorRate: getErrorRate(), + sampledRequests: metrics.length, + }, + generatedAt: new Date().toISOString(), + }); +}); From 97199b8ecc732bba2685cfeea1d9aea7a9257ded Mon Sep 17 00:00:00 2001 From: Francis Ifechukwu Date: Tue, 25 Aug 2026 09:38:20 +0100 Subject: [PATCH 2/2] Revert "feat(admin): add platform health dashboard" --- apps/web/app/admin/page.tsx | 113 +----------------- .../admin/dashboard/__tests__/route.test.ts | 73 ----------- apps/web/app/api/admin/dashboard/route.ts | 43 ------- 3 files changed, 1 insertion(+), 228 deletions(-) delete mode 100644 apps/web/app/api/admin/dashboard/__tests__/route.test.ts delete mode 100644 apps/web/app/api/admin/dashboard/route.ts diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index 708fd707e..840f2431a 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -10,9 +10,6 @@ import { Sparkles, Shield, ShieldAlert, - Users, - Activity, - ClipboardList, } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; @@ -25,14 +22,6 @@ import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { getAllHuntsIncludingPrivate, setLocalFeaturedHunt } from "@/lib/huntStore"; import type { StoredHunt } from "@/lib/types"; -type DashboardHealth = { - hunts: { total: number; active: number; pendingReview: number }; - players: { registrations: number }; - moderation: { pending: number }; - api: { errorRate: number; sampledRequests: number }; - generatedAt: string; -}; - function StatusBadge({ status }: { status: StoredHunt["status"] }) { const config: Partial> = { Draft: @@ -56,52 +45,10 @@ function StatusBadge({ status }: { status: StoredHunt["status"] }) { ); } -function HealthCard({ - icon: Icon, - label, - value, - detail, - href, -}: { - icon: typeof Trophy; - label: string; - value: string | number; - detail: string; - href?: string; -}) { - const content = ( -
-
- {label} - -
-

{value}

-

{detail}

-
- ); - return href ? ( - - {content} - - ) : ( - content - ); -} - export default function AdminPage() { const [hunts, setHunts] = useState([]); const [filter, setFilter] = useState<"all" | "Active" | "Completed" | "Draft">("all"); - const { data: health, isLoading: isHealthLoading } = useQuery({ - queryKey: ["adminDashboardHealth"], - queryFn: async () => { - const res = await fetch("/api/admin/dashboard"); - if (!res.ok) throw new Error("Failed to load platform health"); - return res.json() as Promise; - }, - refetchInterval: 60_000, - }); - // Fetch featured hunt ID from server API const { data: featuredData, refetch: refetchFeatured } = useQuery({ queryKey: ["featuredHuntId"], @@ -279,65 +226,6 @@ export default function AdminPage() { -
-
-
-

- Platform health -

-

- Live operational overview across the platform. -

-
- {health && ( -

- Updated {new Date(health.generatedAt).toLocaleTimeString()} -

- )} -
-
- - - - -
-
- {/* Active curation overview */} {featuredHunt ? (
@@ -505,3 +393,4 @@ export default function AdminPage() { ); } + \ No newline at end of file diff --git a/apps/web/app/api/admin/dashboard/__tests__/route.test.ts b/apps/web/app/api/admin/dashboard/__tests__/route.test.ts deleted file mode 100644 index 3a2906eca..000000000 --- a/apps/web/app/api/admin/dashboard/__tests__/route.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const getAllHunts = vi.fn(); -const getPendingSubmissions = vi.fn(); -const getErrorRate = vi.fn(); -const getMetrics = vi.fn(); - -vi.mock("@/lib/huntStore", () => ({ getAllHunts })); -vi.mock("@/lib/moderation/dbStore", () => ({ getPendingSubmissions })); -vi.mock("@/lib/monitoring/apiMonitor", () => ({ getErrorRate, getMetrics })); -vi.mock("@sentry/nextjs", () => ({ captureEvent: vi.fn(), captureException: vi.fn() })); - -async function loadRoute() { - vi.resetModules(); - return import("../route"); -} - -function request(token?: string) { - return new Request("http://localhost/api/admin/dashboard", { - headers: token ? { authorization: `Bearer ${token}` } : undefined, - }); -} - -describe("GET /api/admin/dashboard", () => { - const originalSecret = process.env.ADMIN_API_SECRET; - - beforeEach(() => { - process.env.ADMIN_API_SECRET = "admin-secret"; - getAllHunts.mockReturnValue([ - { status: "Active", playerCount: 3 }, - { status: "PendingReview", playerCount: 2 }, - { status: "Completed", playerCount: 1 }, - ]); - getPendingSubmissions.mockResolvedValue([{ id: "pending-1" }]); - getErrorRate.mockReturnValue(0.025); - getMetrics.mockReturnValue([{}, {}, {}]); - }); - - afterEach(() => { - if (originalSecret === undefined) delete process.env.ADMIN_API_SECRET; - else process.env.ADMIN_API_SECRET = originalSecret; - vi.clearAllMocks(); - }); - - it("rejects requests without an admin bearer token", async () => { - const { GET } = await loadRoute(); - const response = await GET(request() as never, undefined); - - expect(response.status).toBe(401); - expect(getAllHunts).not.toHaveBeenCalled(); - }); - - it("rejects requests with an invalid admin bearer token", async () => { - const { GET } = await loadRoute(); - const response = await GET(request("wrong-secret") as never, undefined); - - expect(response.status).toBe(401); - expect(getPendingSubmissions).not.toHaveBeenCalled(); - }); - - it("returns platform health metrics for an authorized admin", async () => { - const { GET } = await loadRoute(); - const response = await GET(request("admin-secret") as never, undefined); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - hunts: { total: 3, active: 1, pendingReview: 1 }, - players: { registrations: 6 }, - moderation: { pending: 1 }, - api: { errorRate: 0.025, sampledRequests: 3 }, - }); - }); -}); diff --git a/apps/web/app/api/admin/dashboard/route.ts b/apps/web/app/api/admin/dashboard/route.ts deleted file mode 100644 index 3a43b7534..000000000 --- a/apps/web/app/api/admin/dashboard/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextResponse } from "next/server"; - -import { assertAdminAuth } from "@/lib/api/adminAuth"; -import { withErrorHandling } from "@/lib/api/withErrorHandling"; -import { getAllHunts } from "@/lib/huntStore"; -import { getPendingSubmissions } from "@/lib/moderation/dbStore"; -import { getErrorRate, getMetrics } from "@/lib/monitoring/apiMonitor"; - -/** - * GET /api/admin/dashboard - * - * Returns the platform-wide counters displayed by the admin overview. This - * endpoint intentionally uses the same admin bearer-token guard as the other - * admin APIs because the aggregate data is not public operational telemetry. - */ -export const GET = withErrorHandling(async (req: Request) => { - assertAdminAuth(req); - - const [hunts, pendingSubmissions] = await Promise.all([ - Promise.resolve(getAllHunts()), - getPendingSubmissions(), - ]); - const metrics = getMetrics(); - - return NextResponse.json({ - hunts: { - total: hunts.length, - active: hunts.filter((hunt) => hunt.status === "Active").length, - pendingReview: hunts.filter((hunt) => hunt.status === "PendingReview").length, - }, - players: { - registrations: hunts.reduce((total, hunt) => total + (hunt.playerCount ?? 0), 0), - }, - moderation: { - pending: pendingSubmissions.length, - }, - api: { - errorRate: getErrorRate(), - sampledRequests: metrics.length, - }, - generatedAt: new Date().toISOString(), - }); -});