diff --git a/apps/dashboard/app/api/members/route.ts b/apps/dashboard/app/api/members/route.ts index 193bc0d..10ba290 100644 --- a/apps/dashboard/app/api/members/route.ts +++ b/apps/dashboard/app/api/members/route.ts @@ -13,12 +13,15 @@ import { assertPermission, PermissionDeniedError } from "@/lib/permissions"; import { IntegrationClient } from "@guildpass/integration-client"; import { getEnv, getApiMode } from "@/lib/env"; import { getMemberRepository } from "@/lib/repositories/factory"; +import { filterMembers, paginateItems, parseListLimit, parseListPage } from "@/lib/pagination"; +import type { MemberListQuery } from "@/lib/repositories/types"; import { malformedPayloadError, validateMemberCreatePayload, validateMemberUpdatePayload, } from "@/lib/validation/mutations"; import { recordDashboardActivity } from "@/lib/activity/dashboard"; +import { isMemberRole } from "@/lib/member-roles"; export async function GET(request: Request): Promise { return handleApiError(async () => { @@ -27,6 +30,7 @@ export async function GET(request: Request): Promise { const url = new URL(request.url); const wallet = url.searchParams.get("wallet"); const discordUserId = url.searchParams.get("discordUserId"); + const query = parseMemberListQuery(request); if (apiMode === "live") { const testClient = (globalThis as any).__TEST_INTEGRATION_CLIENT; @@ -82,14 +86,40 @@ export async function GET(request: Request): Promise { try { const memberRepository = getMemberRepository(); - return apiResponse(await memberRepository.getAll()); + return apiResponse(await memberRepository.query(query)); } catch (error) { console.error("Error fetching members:", error); - return apiResponse(mockMembers as Member[]); + return apiResponse(getFallbackMembers(query)); } }); } +const MEMBER_STATUSES: Member["status"][] = ["active", "inactive", "pending"]; + +function parseMemberListQuery(request: Request): MemberListQuery { + const { searchParams } = new URL(request.url); + const status = searchParams.get("status"); + const role = searchParams.get("role"); + + return { + search: searchParams.get("search") ?? undefined, + status: isMemberStatus(status) ? status : "all", + role: role && isMemberRole(role) ? role : "all", + limit: parseListLimit(searchParams.get("limit")), + page: parseListPage(searchParams.get("page")), + cursor: searchParams.get("cursor"), + }; +} + +function isMemberStatus(value: string | null): value is Member["status"] { + return value !== null && MEMBER_STATUSES.includes(value as Member["status"]); +} + +function getFallbackMembers(query: MemberListQuery) { + const filtered = filterMembers(mockMembers, query); + return paginateItems(filtered, query); +} + export async function POST(request: Request): Promise { let session; try { diff --git a/apps/dashboard/app/api/passes/route.ts b/apps/dashboard/app/api/passes/route.ts index 0e3f757..afc3a41 100644 --- a/apps/dashboard/app/api/passes/route.ts +++ b/apps/dashboard/app/api/passes/route.ts @@ -11,6 +11,8 @@ import { requireDashboardSession, UnauthorizedError } from "@/lib/auth/server-se import { assertPermission, PermissionDeniedError } from "@/lib/permissions"; import { getApiMode } from "@/lib/env"; import { getPassRepository } from "@/lib/repositories/factory"; +import type { PassListQuery } from "@/lib/repositories/types"; +import { filterPasses, paginateItems, parseListLimit, parseListPage } from "@/lib/pagination"; import { malformedPayloadError, validatePassCreatePayload, @@ -18,9 +20,14 @@ import { } from "@/lib/validation/mutations"; import { recordDashboardActivity } from "@/lib/activity/dashboard"; -export async function GET(): Promise { +const PASS_STATUSES: Pass["status"][] = ["active", "inactive", "draft"]; + +export async function GET( + request: Request = new Request("http://localhost/api/passes") +): Promise { return handleApiError(async () => { const apiMode = getApiMode(); + const query = parsePassListQuery(request); if (apiMode === "live") { return apiUnsupported( @@ -32,14 +39,36 @@ export async function GET(): Promise { try { const passRepository = getPassRepository(); - return await passRepository.getAll(); + return await passRepository.query(query); } catch (error) { console.error("Error fetching passes:", error); - return mockPasses as Pass[]; + return getFallbackPasses(query); } }); } +function parsePassListQuery(request: Request): PassListQuery { + const { searchParams } = new URL(request.url); + const status = searchParams.get("status"); + + return { + search: searchParams.get("search") ?? undefined, + status: isPassStatus(status) ? status : "all", + limit: parseListLimit(searchParams.get("limit")), + page: parseListPage(searchParams.get("page")), + cursor: searchParams.get("cursor"), + }; +} + +function isPassStatus(value: string | null): value is Pass["status"] { + return value !== null && PASS_STATUSES.includes(value as Pass["status"]); +} + +function getFallbackPasses(query: PassListQuery) { + const filtered = filterPasses(mockPasses, query); + return paginateItems(filtered, query); +} + export async function POST(request: Request): Promise { let session; try { diff --git a/apps/dashboard/app/dashboard/page.tsx b/apps/dashboard/app/dashboard/page.tsx index 8ce20c5..fba14ce 100644 --- a/apps/dashboard/app/dashboard/page.tsx +++ b/apps/dashboard/app/dashboard/page.tsx @@ -1,75 +1,72 @@ "use client"; import DashboardLayout from "@/components/DashboardLayout"; +import LastUpdated from "@/components/LastUpdated"; import StatCard from "@/components/StatCard"; import StatusBadge from "@/components/StatusBadge"; -import LastUpdated from "@/components/LastUpdated"; import UnsupportedBanner from "@/components/UnsupportedBanner"; +import { ApiClientError, readApiResult } from "@/lib/api-client"; +import { getClientApiMode } from "@/lib/client-env"; import { useActivityFeed } from "@/lib/hooks/useActivityFeed"; -import { mockPasses, mockGuilds, mockMembers, type Member as MockMember } from "@/lib/mock-data"; -import { readApiResult } from "@/lib/api-client"; +import { mockGuilds, mockMembers, mockPasses, type Member as MockMember } from "@/lib/mock-data"; +import type { PaginatedResult } from "@/lib/repositories/types"; import { useEffect, useState } from "react"; -import { fetchList } from "@/lib/fetch-list"; -import { getClientApiMode } from "@/lib/client-env"; type UnsupportedResource = "passes" | "guilds" | "members"; export default function DashboardPage() { const { events, lastUpdated, refresh, refreshing } = useActivityFeed({ limit: 5 }); const { intervalMs } = getActivityRefreshConfig(); + const apiMode = getClientApiMode(); - const [passesCount, setPassesCount] = useState(mockPasses.length); - const [guildsCount, setGuildsCount] = useState(mockGuilds.length); - const [activeMembersCount, setActiveMembersCount] = useState( + const [passesCount, setPassesCount] = useState(mockPasses.length); + const [guildsCount, setGuildsCount] = useState(mockGuilds.length); + const [activeMembersCount, setActiveMembersCount] = useState( mockMembers.filter((m) => m.status === "active").length ); - const [unsupportedResources, setUnsupportedResources] = useState< - UnsupportedResource[] - >([]); + const [unsupportedResources, setUnsupportedResources] = useState([]); const [hasError, setHasError] = useState(false); - const apiMode = getClientApiMode(); - useEffect(() => { let mounted = true; + async function load() { + const unsupported: UnsupportedResource[] = []; + let encounteredError = false; + try { const [passesRes, guildsRes, membersRes] = await Promise.all([ - fetch('/api/passes'), - fetch('/api/guilds'), - fetch('/api/members'), + fetch("/api/passes?limit=1"), + fetch("/api/guilds"), + fetch("/api/members?status=active&limit=1"), ]); - if (mounted) { - const [passes, guilds, members] = await Promise.all([ - readApiResult(passesRes), - readApiResult(guildsRes), - readApiResult(membersRes), - ]); + const [passes, guilds, members] = await Promise.all([ + readApiResult>(passesRes), + readApiResult(guildsRes), + readApiResult>(membersRes), + ]); - setPassesCount(passes.length); - setGuildsCount(guilds.length); - setActiveMembersCount(members.filter((mm) => mm.status === 'active').length); - } + if (!mounted) return; + setPassesCount(passes.total); + setGuildsCount(guilds.length); + setActiveMembersCount(members.total); } catch (err) { - console.warn('Dashboard stats fetch failed, using mock counts', err); - } + if (err instanceof ApiClientError && err.code === "UNSUPPORTED") { + unsupported.push("passes", "guilds", "members"); + } else if (apiMode === "live") { + encounteredError = true; + } - // ── Members ───────────────────────────────────────────── - if (membersResult.ok) { - const members = membersResult.data as MockMember[]; - setActiveMembersCount( - members.filter((m) => m.status === "active").length - ); - } else if (membersResult.code === "UNSUPPORTED_IN_LIVE_MODE") { - unsupported.push("members"); - } else if (apiMode === "live") { - encounteredError = true; + console.warn("Dashboard stats fetch failed, using mock counts", err); } - setUnsupportedResources(unsupported); - setHasError(encounteredError); + if (mounted) { + setUnsupportedResources(unsupported); + setHasError(encounteredError); + } } + load(); return () => { mounted = false; @@ -79,13 +76,12 @@ export default function DashboardPage() { const allUnsupported = unsupportedResources.length === 3 || (unsupportedResources.length > 0 && - ["passes", "guilds", "members"].every((r) => - unsupportedResources.includes(r as UnsupportedResource) + ["passes", "guilds", "members"].every((resource) => + unsupportedResources.includes(resource as UnsupportedResource) )); return ( - {/* ── Unsupported banner (live mode, no list endpoints) ──── */} {allUnsupported && ( +

Some dashboard stats failed to load. Check the API configuration.

)} - {/* ── Stat cards ──────────────────────────────────────────── */} -
- - - - +
+ + + +
-
- {/* ── Live recent activity ────────────────────────────────── */} -
-
+
+
+

Recent Activity

0} /> @@ -127,63 +121,42 @@ export default function DashboardPage() {
- {unsupportedResources.includes("passes") ? ( -
- Activity is tracked via webhooks and is independent of list endpoints. -
- ) : ( -
    - {events.slice(0, 5).map((activity) => ( -
  • -
    -
    -

    {activity.description}

    -

    - {new Date(activity.timestamp).toLocaleString()} -

    -
    -
  • - ))} -
- )} +
    + {events.slice(0, 5).map((activity) => ( +
  • +
    +
    +

    {activity.description}

    +

    + {new Date(activity.timestamp).toLocaleString()} +

    +
    +
  • + ))} +
- {/* ── Recent passes (static) ──────────────────────────────── */} -
-

Recent Passes

+
+

Recent Passes

{unsupportedResources.includes("passes") ? ( -
+
Pass listing is not available in live mode.
) : (
    {mockPasses.slice(0, 4).map((pass) => ( -
  • +
  • {pass.name}

    -

    {pass.currentSupply} / {pass.maxSupply ?? "∞"}

    +

    + {pass.currentSupply} / {pass.maxSupply ?? "unlimited"} +

  • @@ -195,3 +168,9 @@ export default function DashboardPage() { ); } + +function getActivityRefreshConfig() { + const raw = process.env.NEXT_PUBLIC_ACTIVITY_REFRESH_MS; + const parsed = raw ? Number(raw) : 0; + return { intervalMs: Number.isFinite(parsed) && parsed > 0 ? parsed : 0 }; +} diff --git a/apps/dashboard/app/members/page.tsx b/apps/dashboard/app/members/page.tsx index 3d9b2f5..3d75431 100644 --- a/apps/dashboard/app/members/page.tsx +++ b/apps/dashboard/app/members/page.tsx @@ -1,67 +1,103 @@ "use client"; -/** - * app/members/page.tsx - * - * Members management page. - * - * Visibility rules: - * - Table (read) — visible to ALL roles (members:read). - * - "Invite Member" button — visible only when canManageMembers() is true (members:write). - * - "Remove" / "Change Role" row actions — same guard. - * - * Note: Mutation handlers must enforce permissions server-side via - * assertPermission. UI hiding is convenience only. - */ - import DashboardLayout from "@/components/DashboardLayout"; -import StatusBadge from "@/components/StatusBadge"; +import EmptyState from "@/components/EmptyState"; import RoleEditor from "@/components/RoleEditor"; +import StatusBadge from "@/components/StatusBadge"; import UnsupportedBanner from "@/components/UnsupportedBanner"; -import { mockMembers, type Member as MockMember } from "@/lib/mock-data"; +import { ApiClientError, readApiResult } from "@/lib/api-client"; +import { getClientApiMode } from "@/lib/client-env"; import { useSession } from "@/lib/hooks/useSession"; -import { canManageMembers } from "@/lib/permissions"; -import { useEffect, useState, useRef } from "react"; import { useOptimisticMutation } from "@/lib/hooks/useOptimisticMutation"; -import { readApiResult } from "@/lib/api-client"; +import { MEMBER_ROLES } from "@/lib/member-roles"; +import { mockMembers, type Member as MockMember } from "@/lib/mock-data"; +import { canManageMembers } from "@/lib/permissions"; +import type { PaginatedResult } from "@/lib/repositories/types"; +import { useEffect, useMemo, useRef, useState } from "react"; + +type ListState = "loading" | "loaded" | "unsupported" | "error"; +type MemberStatusFilter = MockMember["status"] | "all"; +type MemberRoleFilter = (typeof MEMBER_ROLES)[number] | "all"; + +const PAGE_SIZE = 10; + +const emptyPage: PaginatedResult = { + items: [], + total: 0, + limit: PAGE_SIZE, + page: 1, + nextCursor: null, + hasNextPage: false, + hasPreviousPage: false, +}; export default function MembersPage() { const session = useSession(); const canWrite = canManageMembers(session); - const [members, setMembers] = useState(mockMembers); + const apiMode = getClientApiMode(); + + const [members, setMembers] = useState(mockMembers.slice(0, PAGE_SIZE)); + const [pagination, setPagination] = useState>({ + ...emptyPage, + items: mockMembers.slice(0, PAGE_SIZE), + total: mockMembers.length, + hasNextPage: mockMembers.length > PAGE_SIZE, + }); const [pendingIds, setPendingIds] = useState>(new Set()); - const [listState, setListState] = useState<"loading" | "loaded" | "unsupported" | "error">("loading"); + const [listState, setListState] = useState("loading"); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState("all"); + const [role, setRole] = useState("all"); + const [page, setPage] = useState(1); + const debouncedSearch = useDebouncedValue(search, 250); const previousMembersRef = useRef(members); - const apiMode = getClientApiMode(); - const [isInviteOpen, setIsInviteOpen] = useState(false); - const [inviteLoading, setInviteLoading] = useState(false); + const [isInviteOpen, setIsInviteOpen] = useState(false); + const [inviteLoading, setInviteLoading] = useState(false); + const [form, setForm] = useState({ name: "", wallet: "" }); - const [form, setForm] = useState({ - name: "", - wallet: "", -}); + useEffect(() => { + setPage(1); + }, [debouncedSearch, role, status]); useEffect(() => { let mounted = true; + async function load() { + setListState("loading"); try { - const res = await fetch("/api/members"); - const data = await readApiResult(res); - if (mounted) { - setMembers(data); - previousMembersRef.current = data; - } + const params = new URLSearchParams({ + limit: String(PAGE_SIZE), + page: String(page), + }); + if (debouncedSearch.trim()) params.set("search", debouncedSearch.trim()); + if (status !== "all") params.set("status", status); + if (role !== "all") params.set("role", role); + + const res = await fetch(`/api/members?${params.toString()}`); + const data = await readApiResult>(res); + if (!mounted) return; + + setMembers(data.items); + setPagination(data); + previousMembersRef.current = data.items; + setListState("loaded"); } catch (err) { - // fallback to mockMembers (already the default) + if (!mounted) return; + if (err instanceof ApiClientError && err.code === "UNSUPPORTED") { + setListState("unsupported"); + return; + } console.warn("Falling back to mock members:", err); + setListState(apiMode === "live" ? "error" : "loaded"); } } + load(); return () => { mounted = false; }; - }, [apiMode]); + }, [apiMode, debouncedSearch, page, role, status]); const updateMutation = useOptimisticMutation }>({ mutationFn: async ({ id, data }) => { @@ -74,9 +110,7 @@ export default function MembersPage() { }, onOptimisticUpdate: ({ id, data }) => { previousMembersRef.current = members; - setMembers((prev) => - prev.map((m) => (m.id === id ? { ...m, ...data } : m)) - ); + setMembers((prev) => prev.map((m) => (m.id === id ? { ...m, ...data } : m))); setPendingIds((prev) => new Set(prev).add(id)); }, onRollback: (_error, { id }) => { @@ -88,9 +122,7 @@ export default function MembersPage() { }); }, onSuccess: (updatedMember, { id }) => { - setMembers((prev) => - prev.map((m) => (m.id === id ? updatedMember : m)) - ); + setMembers((prev) => prev.map((m) => (m.id === id ? updatedMember : m))); setPendingIds((prev) => { const next = new Set(prev); next.delete(id); @@ -99,14 +131,12 @@ export default function MembersPage() { }, onError: (error) => { alert(error.message); - } + }, }); const deleteMutation = useOptimisticMutation<{ success: boolean }, string>({ mutationFn: async (id) => { - const res = await fetch(`/api/members?id=${id}`, { - method: "DELETE", - }); + const res = await fetch(`/api/members?id=${id}`, { method: "DELETE" }); return readApiResult<{ success: boolean }>(res); }, onOptimisticUpdate: (id) => { @@ -116,9 +146,10 @@ export default function MembersPage() { }, onRollback: () => { setMembers(previousMembersRef.current); - setPendingIds(new Set()); // Reset all pending since we restore full state + setPendingIds(new Set()); }, onSuccess: (_data, id) => { + setPagination((prev) => ({ ...prev, total: Math.max(0, prev.total - 1) })); setPendingIds((prev) => { const next = new Set(prev); next.delete(id); @@ -127,212 +158,245 @@ export default function MembersPage() { }, onError: (error) => { alert(error.message); - } + }, }); + const resultSummary = useMemo(() => { + if (pagination.total === 0) return "No members found"; + const start = (pagination.page - 1) * pagination.limit + 1; + const end = start + members.length - 1; + return `Showing ${start}-${end} of ${pagination.total} members`; + }, [members.length, pagination]); + const handleRemove = (id: string) => { if (confirm("Are you sure you want to remove this member?")) { deleteMutation.mutate(id); } }; - // Roles are edited inline through the RoleEditor (removable chips + a select - // of supported roles), which can only ever produce a valid, de-duplicated - // list. The API route re-validates server-side before persisting. const handleRolesChange = (id: string, roles: string[]) => { updateMutation.mutate({ id, data: { roles } }); }; return ( - {/* ── Unsupported banner (live mode) ──────────────────────────────── */} - {listState === "unsupported" && ( - - )} + {listState === "unsupported" && } - {/* ── Error banner (live mode network error) ─────────────────────── */} {listState === "error" && ( -
    +

    Failed to load members from the server. Check your API configuration and try again.

    )} - {/* ── Page header ─────────────────────────────────────────────────── */} -
    +

    - {listState === "unsupported" - ? "Member listing unavailable in live mode" - : `${members.length} member${members.length !== 1 ? "s" : ""} total`} + {listState === "unsupported" ? "Member listing unavailable in live mode" : resultSummary}

    - {/* Invite button — write roles only */} {canWrite && listState !== "unsupported" && ( )}
    - {isInviteOpen && ( -
    -
    + {listState !== "unsupported" && ( +
    + -

    Invite Member

    + - - setForm({ ...form, name: e.target.value }) - } - className="w-full border p-2 rounded" - /> - - - setForm({ ...form, wallet: e.target.value }) - } - className="w-full border p-2 rounded" - /> - -
    - - -
    + )} - if (!form.wallet.trim()) { - alert("Wallet is required"); - return; - } + {isInviteOpen && ( +
    +
    +

    Invite Member

    +
    + setForm({ ...form, name: e.target.value })} className="w-full rounded-lg border border-slate-200 p-2" /> + setForm({ ...form, wallet: e.target.value })} className="w-full rounded-lg border border-slate-200 p-2" /> +
    +
    + + +
    +
    +
    + )} - const newMember = await readApiResult(res); + {listState !== "unsupported" && ( + <> +
    +
    + + + + + + + + + {canWrite && } + + + + {members.map((member) => { + const isPending = pendingIds.has(member.id); + return ( + + + + + + + {canWrite && ( + + )} + + ); + })} + +
    NameWalletStatusRolesLast ActiveActions
    + {member.name} + {isPending && (updating...)} + {member.wallet.slice(0, 6)}...{member.wallet.slice(-4)} + handleRolesChange(member.id, roles)} /> + {new Date(member.lastActive).toLocaleDateString()} + +
    +
    +
    -// Fallback safety (prevents roles/status undefined bugs) -const safeMember = { - ...newMember, - roles: newMember.roles ?? [], - status: newMember.status ?? "pending", -}; + {members.length === 0 && ( +
    + +
    + )} -setMembers((prev) => [safeMember, ...prev]); + setPage((current) => Math.max(1, current - 1))} + onNext={() => setPage((current) => current + 1)} + /> + + )} + + ); +} - setIsInviteOpen(false); +function PaginationControls({ + page, + hasPreviousPage, + hasNextPage, + onPrevious, + onNext, +}: { + page: number; + hasPreviousPage: boolean; + hasNextPage: boolean; + onPrevious: () => void; + onNext: () => void; +}) { + return ( +
    + + Page {page} + +
    + ); +} - setForm({ - name: "", - wallet: "", - }); - } catch (e: any) { - alert(e.message); - } finally { - setInviteLoading(false); - } - }} -> - {inviteLoading ? "Inviting..." : "Invite"} - -
    +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); -
    -
    -)} + useEffect(() => { + const timeoutId = window.setTimeout(() => setDebounced(value), delayMs); + return () => window.clearTimeout(timeoutId); + }, [delayMs, value]); - {/* ── Members table ───────────────────────────────────────────────── */} - {listState !== "unsupported" && ( -
    -
    - - - - - - - - - {/* Actions column only rendered for write-capable roles */} - {canWrite && ( - - )} - - - - {members.map((member) => { - const isPending = pendingIds.has(member.id); - return ( - - - - - - - {canWrite && ( - - )} - - ); - })} - -
    NameWalletStatusRolesLast ActiveActions
    - {member.name} - {isPending && (updating...)} - - {member.wallet.slice(0, 6)}...{member.wallet.slice(-4)} - - - - handleRolesChange(member.id, roles)} - /> - - {new Date(member.lastActive).toLocaleDateString()} - -
    - -
    -
    -
    -
    - )} - - ); + return debounced; } diff --git a/apps/dashboard/app/passes/page.tsx b/apps/dashboard/app/passes/page.tsx index b65a307..0bd56a6 100644 --- a/apps/dashboard/app/passes/page.tsx +++ b/apps/dashboard/app/passes/page.tsx @@ -1,67 +1,103 @@ "use client"; -/** - * app/passes/page.tsx - * - * Passes management page. - * - * Visibility rules: - * - Table (read) — visible to ALL roles (passes:read). - * - "Create Pass" button — visible only when canManagePasses() is true (passes:write). - * - "Edit" / "Deactivate" row actions — same guard. - * - * Note: The actual mutation handlers (form submissions, API calls) must also - * enforce permissions server-side via assertPermission. UI hiding is convenience - * only and must not be the sole security boundary. - */ - import DashboardLayout from "@/components/DashboardLayout"; +import EmptyState from "@/components/EmptyState"; import StatusBadge from "@/components/StatusBadge"; import UnsupportedBanner from "@/components/UnsupportedBanner"; +import { ApiClientError, readApiResult } from "@/lib/api-client"; +import { getClientApiMode } from "@/lib/client-env"; import { mockPasses, type Pass as MockPass } from "@/lib/mock-data"; +import type { PaginatedResult } from "@/lib/repositories/types"; import { useSession } from "@/lib/hooks/useSession"; -import { canManagePasses } from "@/lib/permissions"; -import { useEffect, useState, useRef } from "react"; import { useOptimisticMutation } from "@/lib/hooks/useOptimisticMutation"; -import { readApiResult } from "@/lib/api-client"; +import { canManagePasses } from "@/lib/permissions"; +import { useEffect, useMemo, useRef, useState } from "react"; + +type ListState = "loading" | "loaded" | "unsupported" | "error"; +type PassStatusFilter = MockPass["status"] | "all"; + +const PAGE_SIZE = 10; + +const emptyPage: PaginatedResult = { + items: [], + total: 0, + limit: PAGE_SIZE, + page: 1, + nextCursor: null, + hasNextPage: false, + hasPreviousPage: false, +}; export default function PassesPage() { const session = useSession(); const canWrite = canManagePasses(session); - const [passes, setPasses] = useState(mockPasses); + const apiMode = getClientApiMode(); + + const [passes, setPasses] = useState(mockPasses.slice(0, PAGE_SIZE)); + const [pagination, setPagination] = useState>({ + ...emptyPage, + items: mockPasses.slice(0, PAGE_SIZE), + total: mockPasses.length, + hasNextPage: mockPasses.length > PAGE_SIZE, + }); const [pendingIds, setPendingIds] = useState>(new Set()); - const [listState, setListState] = useState<"loading" | "loaded" | "unsupported" | "error">("loading"); + const [listState, setListState] = useState("loading"); + const [search, setSearch] = useState(""); + const [status, setStatus] = useState("all"); + const [page, setPage] = useState(1); + const debouncedSearch = useDebouncedValue(search, 250); const previousPassesRef = useRef(passes); - const apiMode = getClientApiMode(); const [isCreateOpen, setIsCreateOpen] = useState(false); const [createLoading, setCreateLoading] = useState(false); const [form, setForm] = useState({ - name: "", - description: "", - price: "", - maxSupply: "", - }); + name: "", + description: "", + price: "", + maxSupply: "", + }); + + useEffect(() => { + setPage(1); + }, [debouncedSearch, status]); useEffect(() => { let mounted = true; + async function load() { + setListState("loading"); try { - const res = await fetch("/api/passes"); - const data = await readApiResult(res); - if (mounted) { - setPasses(data); - previousPassesRef.current = data; - } + const params = new URLSearchParams({ + limit: String(PAGE_SIZE), + page: String(page), + }); + if (debouncedSearch.trim()) params.set("search", debouncedSearch.trim()); + if (status !== "all") params.set("status", status); + + const res = await fetch(`/api/passes?${params.toString()}`); + const data = await readApiResult>(res); + if (!mounted) return; + + setPasses(data.items); + setPagination(data); + previousPassesRef.current = data.items; + setListState("loaded"); } catch (err) { + if (!mounted) return; + if (err instanceof ApiClientError && err.code === "UNSUPPORTED") { + setListState("unsupported"); + return; + } console.warn("Falling back to mock passes:", err); + setListState(apiMode === "live" ? "error" : "loaded"); } } + load(); return () => { mounted = false; }; - }, [apiMode]); + }, [apiMode, debouncedSearch, page, status]); const updateMutation = useOptimisticMutation }>({ mutationFn: async ({ id, data }) => { @@ -74,9 +110,7 @@ export default function PassesPage() { }, onOptimisticUpdate: ({ id, data }) => { previousPassesRef.current = passes; - setPasses((prev) => - prev.map((p) => (p.id === id ? { ...p, ...data } : p)) - ); + setPasses((prev) => prev.map((p) => (p.id === id ? { ...p, ...data } : p))); setPendingIds((prev) => new Set(prev).add(id)); }, onRollback: (_error, { id }) => { @@ -88,9 +122,7 @@ export default function PassesPage() { }); }, onSuccess: (updatedPass, { id }) => { - setPasses((prev) => - prev.map((p) => (p.id === id ? updatedPass : p)) - ); + setPasses((prev) => prev.map((p) => (p.id === id ? updatedPass : p))); setPendingIds((prev) => { const next = new Set(prev); next.delete(id); @@ -99,223 +131,238 @@ export default function PassesPage() { }, onError: (error) => { alert(error.message); - } + }, }); + const resultSummary = useMemo(() => { + if (pagination.total === 0) return "No passes found"; + const start = (pagination.page - 1) * pagination.limit + 1; + const end = start + passes.length - 1; + return `Showing ${start}-${end} of ${pagination.total} passes`; + }, [pagination, passes.length]); + const handleDeactivate = (id: string) => { updateMutation.mutate({ id, data: { status: "inactive" } }); }; const handleEdit = (id: string) => { const name = prompt("Enter new name:"); - if (name) { - updateMutation.mutate({ id, data: { name } }); + if (name?.trim()) { + updateMutation.mutate({ id, data: { name: name.trim() } }); } }; - - return ( - {/* ── Unsupported banner (live mode) ──────────────────────────────── */} - {listState === "unsupported" && ( - - )} + {listState === "unsupported" && } - {/* ── Error banner (live mode network error) ─────────────────────── */} {listState === "error" && ( -
    +

    Failed to load passes from the server. Check your API configuration and try again.

    )} - {/* ── Page header ─────────────────────────────────────────────────── */} -
    +

    - {listState === "unsupported" - ? "Pass listing unavailable in live mode" - : `${passes.length} pass${passes.length !== 1 ? "es" : ""} total`} + {listState === "unsupported" ? "Pass listing unavailable in live mode" : resultSummary}

    - {/* Create button — write roles only */} {canWrite && ( - )}
    + {listState !== "unsupported" && ( +
    + + + +
    + )} + {isCreateOpen && ( -
    -
    - -

    Create Pass

    - - setForm({ ...form, name: e.target.value })} - className="w-full border p-2 rounded" - /> - - setForm({ ...form, description: e.target.value })} - className="w-full border p-2 rounded" - /> - - setForm({ ...form, price: e.target.value })} - className="w-full border p-2 rounded" - /> - - setForm({ ...form, maxSupply: e.target.value })} - className="w-full border p-2 rounded" - /> - -
    - - - -
    -
    -
    -)} +
    +
    +

    Create Pass

    +
    + setForm({ ...form, name: e.target.value })} className="w-full rounded-lg border border-slate-200 p-2" /> + setForm({ ...form, description: e.target.value })} className="w-full rounded-lg border border-slate-200 p-2" /> + setForm({ ...form, price: e.target.value })} className="w-full rounded-lg border border-slate-200 p-2" /> + setForm({ ...form, maxSupply: e.target.value })} className="w-full rounded-lg border border-slate-200 p-2" /> +
    +
    + + +
    +
    +
    + )} - {/* ── Passes table ────────────────────────────────────────────────── */} {listState !== "unsupported" && ( -
    -
    - - - - - - - - - {/* Actions column only rendered for write-capable roles */} - {canWrite && ( - - )} - - - - {passes.map((pass) => { - const isPending = pendingIds.has(pass.id); - return ( - - - - - - - {canWrite && ( - - )} + <> +
    +
    +
    NameDescriptionStatusPriceSupplyActions
    - {pass.name} - {isPending && (updating...)} - {pass.description} - - - {pass.price !== undefined ? `${pass.price} ETH` : "Free"} - - {pass.currentSupply} / {pass.maxSupply ?? "∞"} - -
    - - · - -
    -
    + + + + + + + + {canWrite && } - ); - })} - -
    NameDescriptionStatusPriceSupplyActions
    -
    -
    + + + {passes.map((pass) => { + const isPending = pendingIds.has(pass.id); + return ( + + + {pass.name} + {isPending && (updating...)} + + {pass.description} + + {pass.price !== undefined ? `${pass.price} ETH` : "Free"} + {pass.currentSupply} / {pass.maxSupply ?? "unlimited"} + {canWrite && ( + +
    + + | + +
    + + )} + + ); + })} + + +
    +
    + + {passes.length === 0 && ( +
    + +
    + )} + + setPage((current) => Math.max(1, current - 1))} + onNext={() => setPage((current) => current + 1)} + /> + )}
    ); } + +function PaginationControls({ + page, + hasPreviousPage, + hasNextPage, + onPrevious, + onNext, +}: { + page: number; + hasPreviousPage: boolean; + hasNextPage: boolean; + onPrevious: () => void; + onNext: () => void; +}) { + return ( +
    + + Page {page} + +
    + ); +} + +function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timeoutId = window.setTimeout(() => setDebounced(value), delayMs); + return () => window.clearTimeout(timeoutId); + }, [delayMs, value]); + + return debounced; +} diff --git a/apps/dashboard/lib/api-contracts.ts b/apps/dashboard/lib/api-contracts.ts index c9899e8..e76d523 100644 --- a/apps/dashboard/lib/api-contracts.ts +++ b/apps/dashboard/lib/api-contracts.ts @@ -16,6 +16,16 @@ export interface ApiSuccess { data: T; } +export interface PaginatedResponse { + items: T[]; + total: number; + limit: number; + page: number; + nextCursor: string | null; + hasNextPage: boolean; + hasPreviousPage: boolean; +} + export interface ApiErrorResponse { ok: false; code: ApiErrorCode; diff --git a/apps/dashboard/lib/pagination.ts b/apps/dashboard/lib/pagination.ts new file mode 100644 index 0000000..b297347 --- /dev/null +++ b/apps/dashboard/lib/pagination.ts @@ -0,0 +1,102 @@ +import type { + MemberListQuery, + PaginatedResult, + PaginationOptions, + PassListQuery, +} from "@/lib/repositories/types"; +import type { Member, Pass } from "@/lib/mock-data"; + +export const DEFAULT_LIST_LIMIT = 10; +export const MAX_LIST_LIMIT = 50; + +export function normalisePagination(options: PaginationOptions = {}) { + const limit = clampPositiveInteger(options.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); + const cursorPage = decodePageCursor(options.cursor); + const requestedPage = options.page ?? cursorPage ?? 1; + const page = Math.max(1, Math.floor(requestedPage)); + + return { limit, page }; +} + +export function paginateItems( + items: T[], + options: PaginationOptions = {} +): PaginatedResult { + const { limit, page } = normalisePagination(options); + const total = items.length; + const start = (page - 1) * limit; + const pageItems = items.slice(start, start + limit); + const hasNextPage = start + limit < total; + + return { + items: pageItems, + total, + limit, + page, + nextCursor: hasNextPage ? encodePageCursor(page + 1) : null, + hasNextPage, + hasPreviousPage: page > 1, + }; +} + +export function filterPasses(passes: Pass[], query: PassListQuery = {}): Pass[] { + const search = normaliseSearch(query.search); + return passes.filter((pass) => { + const matchesSearch = + !search || + pass.name.toLowerCase().includes(search) || + pass.description.toLowerCase().includes(search); + const matchesStatus = + !query.status || query.status === "all" || pass.status === query.status; + + return matchesSearch && matchesStatus; + }); +} + +export function filterMembers(members: Member[], query: MemberListQuery = {}): Member[] { + const search = normaliseSearch(query.search); + return members.filter((member) => { + const matchesSearch = + !search || + member.name.toLowerCase().includes(search) || + member.wallet.toLowerCase().includes(search); + const matchesStatus = + !query.status || query.status === "all" || member.status === query.status; + const matchesRole = + !query.role || query.role === "all" || member.roles.includes(query.role); + + return matchesSearch && matchesStatus && matchesRole; + }); +} + +export function parseListLimit(value: string | null): number | undefined { + if (!value) return undefined; + return clampPositiveInteger(Number(value), DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); +} + +export function parseListPage(value: string | null): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1; +} + +function normaliseSearch(value?: string): string { + return value?.trim().toLowerCase() ?? ""; +} + +function clampPositiveInteger(value: unknown, fallback: number, max: number): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(Math.floor(parsed), max); +} + +function encodePageCursor(page: number): string { + return `page:${page}`; +} + +function decodePageCursor(cursor?: string | null): number | null { + if (!cursor) return null; + const match = /^page:(\d+)$/.exec(cursor); + if (!match) return null; + return Number(match[1]); +} diff --git a/apps/dashboard/lib/repositories/adapters/durable.ts b/apps/dashboard/lib/repositories/adapters/durable.ts index e62f558..824fe01 100644 --- a/apps/dashboard/lib/repositories/adapters/durable.ts +++ b/apps/dashboard/lib/repositories/adapters/durable.ts @@ -12,6 +12,9 @@ import type { IMemberRepository, IActivityRepository, ISettingsRepository, + MemberListQuery, + PaginatedResult, + PassListQuery, } from "../types"; import type { Pass, Guild, Member } from "../../mock-data"; import type { ActivityEvent } from "@/lib/activity/types"; @@ -54,6 +57,11 @@ export class DurablePassRepository extends DurableRepository implements IPassRep throw new Error("DurablePassRepository not yet implemented. Configure STORAGE_BACKEND in .env"); } + async query(_options: PassListQuery = {}): Promise> { + // Durable backends should push search/filter/pagination into indexed queries. + throw new Error("DurablePassRepository not yet implemented. Configure STORAGE_BACKEND in .env"); + } + async getById(_id: string): Promise { // TODO: Implement throw new Error("DurablePassRepository not yet implemented"); @@ -116,6 +124,11 @@ export class DurableMemberRepository extends DurableRepository implements IMembe throw new Error("DurableMemberRepository not yet implemented"); } + async query(_options: MemberListQuery = {}): Promise> { + // Durable backends should push search/filter/pagination into indexed queries. + throw new Error("DurableMemberRepository not yet implemented"); + } + async getById(_id: string): Promise { throw new Error("DurableMemberRepository not yet implemented"); } diff --git a/apps/dashboard/lib/repositories/adapters/mock.ts b/apps/dashboard/lib/repositories/adapters/mock.ts index a11c681..994e100 100644 --- a/apps/dashboard/lib/repositories/adapters/mock.ts +++ b/apps/dashboard/lib/repositories/adapters/mock.ts @@ -10,12 +10,16 @@ import type { IMemberRepository, IActivityRepository, ISettingsRepository, + MemberListQuery, + PaginatedResult, + PassListQuery, } from "../types"; import type { Pass, Guild, Member } from "../../mock-data"; import type { ActivityEvent } from "@/lib/activity/types"; import type { DashboardSettings } from "../../settings"; import { mockPasses, mockGuilds, mockMembers } from "../../mock-data"; import { DEFAULT_SETTINGS } from "../../settings"; +import { filterMembers, filterPasses, paginateItems } from "@/lib/pagination"; /** * Mock pass repository: in-memory storage. @@ -32,6 +36,11 @@ export class MockPassRepository implements IPassRepository { return Array.from(this.passes.values()); } + async query(options: PassListQuery = {}): Promise> { + const filtered = filterPasses(await this.getAll(), options); + return paginateItems(filtered, options); + } + async getById(id: string): Promise { return this.passes.get(id) ?? null; } @@ -122,6 +131,11 @@ export class MockMemberRepository implements IMemberRepository { return Array.from(this.members.values()); } + async query(options: MemberListQuery = {}): Promise> { + const filtered = filterMembers(await this.getAll(), options); + return paginateItems(filtered, options); + } + async getById(id: string): Promise { return this.members.get(id) ?? null; } diff --git a/apps/dashboard/lib/repositories/types.ts b/apps/dashboard/lib/repositories/types.ts index 9c66bd7..cb2ccde 100644 --- a/apps/dashboard/lib/repositories/types.ts +++ b/apps/dashboard/lib/repositories/types.ts @@ -6,6 +6,26 @@ import type { Pass, Guild, Member } from "../mock-data"; import type { ActivityEvent } from "@/lib/activity/types"; import type { DashboardSettings } from "../settings"; +import type { PaginatedResponse } from "../api-contracts"; + +export interface PaginationOptions { + limit?: number; + cursor?: string | null; + page?: number; +} + +export interface PaginatedResult extends PaginatedResponse {} + +export interface PassListQuery extends PaginationOptions { + search?: string; + status?: Pass["status"] | "all"; +} + +export interface MemberListQuery extends PaginationOptions { + search?: string; + status?: Member["status"] | "all"; + role?: string | "all"; +} /** * Repository for managing passes. @@ -16,6 +36,11 @@ export interface IPassRepository { */ getAll(): Promise; + /** + * Query passes with filtering and bounded pagination. + */ + query(options?: PassListQuery): Promise>; + /** * Get a pass by ID. */ @@ -76,6 +101,11 @@ export interface IMemberRepository { */ getAll(): Promise; + /** + * Query members with filtering and bounded pagination. + */ + query(options?: MemberListQuery): Promise>; + /** * Get a member by ID. */ diff --git a/apps/dashboard/test/api-contracts.test.ts b/apps/dashboard/test/api-contracts.test.ts index 3ff526a..481dbfb 100644 --- a/apps/dashboard/test/api-contracts.test.ts +++ b/apps/dashboard/test/api-contracts.test.ts @@ -15,7 +15,9 @@ describe("dashboard API response contract", () => { assert.equal(response.status, 200); assert.equal(body.ok, true); - assert.ok(Array.isArray(body.data)); + assert.ok(Array.isArray(body.data.items)); + assert.equal(typeof body.data.total, "number"); + assert.ok(body.data.nextCursor === null || typeof body.data.nextCursor === "string"); } finally { restoreEnv("DASHBOARD_API_MODE", previousMode); } diff --git a/apps/dashboard/test/management-pagination.test.ts b/apps/dashboard/test/management-pagination.test.ts new file mode 100644 index 0000000..fcb4b50 --- /dev/null +++ b/apps/dashboard/test/management-pagination.test.ts @@ -0,0 +1,101 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +describe("management list pagination and filtering", () => { + test("GET /api/passes searches by name or description", async () => { + const previousMode = process.env.DASHBOARD_API_MODE; + process.env.DASHBOARD_API_MODE = "mock"; + + try { + const { GET } = await import("../app/api/passes/route.js"); + const response = await GET(new Request("http://localhost/api/passes?search=early")); + const body = await response.json(); + + assert.equal(body.ok, true); + assert.equal(body.data.total, 1); + assert.equal(body.data.items[0].name, "Founder Pass"); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); + + test("GET /api/passes filters by status and paginates", async () => { + const previousMode = process.env.DASHBOARD_API_MODE; + process.env.DASHBOARD_API_MODE = "mock"; + + try { + const { GET } = await import("../app/api/passes/route.js"); + const response = await GET(new Request("http://localhost/api/passes?status=active&limit=2&page=1")); + const body = await response.json(); + + assert.equal(body.ok, true); + assert.equal(body.data.total, 3); + assert.equal(body.data.items.length, 2); + assert.equal(body.data.hasNextPage, true); + assert.ok(body.data.items.every((pass: any) => pass.status === "active")); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); + + test("GET /api/members searches by name or wallet", async () => { + const previousMode = process.env.DASHBOARD_API_MODE; + process.env.DASHBOARD_API_MODE = "mock"; + + try { + const { GET } = await import("../app/api/members/route.js"); + const response = await GET(new Request("http://localhost/api/members?search=90F8")); + const body = await response.json(); + + assert.equal(body.ok, true); + assert.equal(body.data.total, 1); + assert.equal(body.data.items[0].name, "Bob"); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); + + test("GET /api/members filters by status and role", async () => { + const previousMode = process.env.DASHBOARD_API_MODE; + process.env.DASHBOARD_API_MODE = "mock"; + + try { + const { GET } = await import("../app/api/members/route.js"); + const response = await GET(new Request("http://localhost/api/members?status=active&role=contributor")); + const body = await response.json(); + + assert.equal(body.ok, true); + assert.equal(body.data.total, 1); + assert.equal(body.data.items[0].name, "Bob"); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); + + test("GET /api/members returns a clear empty paginated result", async () => { + const previousMode = process.env.DASHBOARD_API_MODE; + process.env.DASHBOARD_API_MODE = "mock"; + + try { + const { GET } = await import("../app/api/members/route.js"); + const response = await GET(new Request("http://localhost/api/members?search=no-such-member")); + const body = await response.json(); + + assert.equal(body.ok, true); + assert.deepEqual(body.data.items, []); + assert.equal(body.data.total, 0); + assert.equal(body.data.nextCursor, null); + assert.equal(body.data.hasNextPage, false); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); +}); + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} diff --git a/apps/dashboard/test/members.test.ts b/apps/dashboard/test/members.test.ts index 7c2d96c..a8e755b 100644 --- a/apps/dashboard/test/members.test.ts +++ b/apps/dashboard/test/members.test.ts @@ -14,8 +14,9 @@ test("GET /api/members returns mock members in mock mode", async () => { const body = await res.json(); assert.strictEqual(body.ok, true); - assert.ok(Array.isArray(body.data), "response data should be an array"); - assert.strictEqual(body.data.length, mockMembers.length); + assert.ok(Array.isArray(body.data.items), "response data should include items"); + assert.strictEqual(body.data.total, mockMembers.length); + assert.strictEqual(body.data.items.length, mockMembers.length); } finally { if (previousMode === undefined) { delete process.env.DASHBOARD_API_MODE; diff --git a/apps/dashboard/test/unsupported-mode.test.ts b/apps/dashboard/test/unsupported-mode.test.ts index 54cf7cc..2c991ff 100644 --- a/apps/dashboard/test/unsupported-mode.test.ts +++ b/apps/dashboard/test/unsupported-mode.test.ts @@ -1,119 +1,79 @@ import { test } from "node:test"; import assert from "node:assert"; -/** - * Tests for the unsupported live mode response pattern. - * - * These tests verify that: - * 1. API routes return { code: "UNSUPPORTED_IN_LIVE_MODE" } when - * DASHBOARD_API_MODE=live and the list endpoint isn't implemented. - * 2. Mock mode still returns actual data (mock fallback works). - * 3. The fetchList utility correctly identifies unsupported responses. - */ - -// ──────────────────────────────────────────────────────────────────────────── -// Passes -// ──────────────────────────────────────────────────────────────────────────── - test("GET /api/passes returns unsupported in live mode", async () => { const previousMode = process.env.DASHBOARD_API_MODE; process.env.DASHBOARD_API_MODE = "live"; try { const { GET } = await import("../app/api/passes/route.js"); - - const req = new Request("http://localhost/api/passes"); - const res: Response = await GET(); - const data = await res.json(); + const res: Response = await GET(new Request("http://localhost/api/passes")); + const body = await res.json(); assert.strictEqual(res.status, 501); - assert.strictEqual(data.code, "UNSUPPORTED_IN_LIVE_MODE"); - assert.ok(typeof data.error === "string"); + assert.strictEqual(body.ok, false); + assert.strictEqual(body.code, "UNSUPPORTED"); + assert.deepStrictEqual(body.unsupported, { feature: "passes.list", mode: "live" }); } finally { - if (previousMode === undefined) { - delete process.env.DASHBOARD_API_MODE; - } else { - process.env.DASHBOARD_API_MODE = previousMode; - } + restoreEnv("DASHBOARD_API_MODE", previousMode); } }); -test("GET /api/passes returns mock data in mock mode", async () => { +test("GET /api/passes returns paginated mock data in mock mode", async () => { const previousMode = process.env.DASHBOARD_API_MODE; process.env.DASHBOARD_API_MODE = "mock"; try { const { GET } = await import("../app/api/passes/route.js"); const { mockPasses } = await import("../lib/mock-data.js"); + const res: Response = await GET(new Request("http://localhost/api/passes")); + const body = await res.json(); - const res: Response = await GET(); - const data = await res.json(); - - assert.ok(Array.isArray(data), "response should be an array"); - assert.strictEqual(data.length, mockPasses.length); + assert.strictEqual(body.ok, true); + assert.ok(Array.isArray(body.data.items), "response should include items"); + assert.strictEqual(body.data.total, mockPasses.length); } finally { - if (previousMode === undefined) { - delete process.env.DASHBOARD_API_MODE; - } else { - process.env.DASHBOARD_API_MODE = previousMode; - } + restoreEnv("DASHBOARD_API_MODE", previousMode); } }); -// ──────────────────────────────────────────────────────────────────────────── -// Guilds -// ──────────────────────────────────────────────────────────────────────────── - test("GET /api/guilds returns unsupported in live mode", async () => { const previousMode = process.env.DASHBOARD_API_MODE; process.env.DASHBOARD_API_MODE = "live"; try { const { GET } = await import("../app/api/guilds/route.js"); - - const req = new Request("http://localhost/api/guilds"); const res: Response = await GET(); - const data = await res.json(); + const body = await res.json(); assert.strictEqual(res.status, 501); - assert.strictEqual(data.code, "UNSUPPORTED_IN_LIVE_MODE"); - assert.ok(typeof data.error === "string"); + assert.strictEqual(body.ok, false); + assert.strictEqual(body.code, "UNSUPPORTED"); + assert.deepStrictEqual(body.unsupported, { feature: "guilds.list", mode: "live" }); } finally { - if (previousMode === undefined) { - delete process.env.DASHBOARD_API_MODE; - } else { - process.env.DASHBOARD_API_MODE = previousMode; - } + restoreEnv("DASHBOARD_API_MODE", previousMode); } }); -test("GET /api/guilds returns mock data in mock mode", async () => { +test("GET /api/guilds returns wrapped mock data in mock mode", async () => { const previousMode = process.env.DASHBOARD_API_MODE; process.env.DASHBOARD_API_MODE = "mock"; try { const { GET } = await import("../app/api/guilds/route.js"); const { mockGuilds } = await import("../lib/mock-data.js"); - const res: Response = await GET(); - const data = await res.json(); + const body = await res.json(); - assert.ok(Array.isArray(data), "response should be an array"); - assert.strictEqual(data.length, mockGuilds.length); + assert.strictEqual(body.ok, true); + assert.ok(Array.isArray(body.data), "response data should be an array"); + assert.strictEqual(body.data.length, mockGuilds.length); } finally { - if (previousMode === undefined) { - delete process.env.DASHBOARD_API_MODE; - } else { - process.env.DASHBOARD_API_MODE = previousMode; - } + restoreEnv("DASHBOARD_API_MODE", previousMode); } }); -// ──────────────────────────────────────────────────────────────────────────── -// Members -// ──────────────────────────────────────────────────────────────────────────── - -test("GET /api/members returns unsupported in live mode without query params", async () => { +test("GET /api/members returns unsupported in live mode without lookup params", async () => { const previousMode = process.env.DASHBOARD_API_MODE; const previousUrl = process.env.GUILD_PASS_CORE_URL; process.env.DASHBOARD_API_MODE = "live"; @@ -121,25 +81,16 @@ test("GET /api/members returns unsupported in live mode without query params", a try { const { GET } = await import("../app/api/members/route.js"); - - const req = new Request("http://localhost/api/members"); - const res: Response = await GET(req); - const data = await res.json(); + const res: Response = await GET(new Request("http://localhost/api/members")); + const body = await res.json(); assert.strictEqual(res.status, 501); - assert.strictEqual(data.code, "UNSUPPORTED_IN_LIVE_MODE"); - assert.ok(typeof data.error === "string"); + assert.strictEqual(body.ok, false); + assert.strictEqual(body.code, "UNSUPPORTED"); + assert.deepStrictEqual(body.unsupported, { feature: "members.list", mode: "live" }); } finally { - if (previousMode === undefined) { - delete process.env.DASHBOARD_API_MODE; - } else { - process.env.DASHBOARD_API_MODE = previousMode; - } - if (previousUrl === undefined) { - delete process.env.GUILD_PASS_CORE_URL; - } else { - process.env.GUILD_PASS_CORE_URL = previousUrl; - } + restoreEnv("DASHBOARD_API_MODE", previousMode); + restoreEnv("GUILD_PASS_CORE_URL", previousUrl); } }); @@ -148,7 +99,6 @@ test("GET /api/members returns data with wallet query in live mode", async () => process.env.DASHBOARD_API_MODE = "live"; try { - // Inject a test client to avoid real HTTP calls (globalThis as any).__TEST_INTEGRATION_CLIENT = { getMembershipByWallet: async (wallet: string) => ({ userId: `u_${wallet.slice(-4)}`, @@ -160,59 +110,53 @@ test("GET /api/members returns data with wallet query in live mode", async () => }; const { GET } = await import("../app/api/members/route.js"); + const res: Response = await GET(new Request("http://localhost/api/members?wallet=0xabc123")); + const body = await res.json(); - const req = new Request("http://localhost/api/members?wallet=0xabc123"); - const res: Response = await GET(req); - const data = await res.json(); - - assert.ok(Array.isArray(data)); - assert.strictEqual(data.length, 1); - assert.strictEqual(data[0].wallet, "0xabc123"); + assert.strictEqual(body.ok, true); + assert.ok(Array.isArray(body.data)); + assert.strictEqual(body.data.length, 1); + assert.strictEqual(body.data[0].wallet, "0xabc123"); } finally { delete (globalThis as any).__TEST_INTEGRATION_CLIENT; - - if (previousMode === undefined) { - delete process.env.DASHBOARD_API_MODE; - } else { - process.env.DASHBOARD_API_MODE = previousMode; - } + restoreEnv("DASHBOARD_API_MODE", previousMode); } }); -test("GET /api/members returns mock data in mock mode", async () => { +test("GET /api/members returns paginated mock data in mock mode", async () => { const previousMode = process.env.DASHBOARD_API_MODE; process.env.DASHBOARD_API_MODE = "mock"; try { const { GET } = await import("../app/api/members/route.js"); const { mockMembers } = await import("../lib/mock-data.js"); + const res: Response = await GET(new Request("http://localhost/api/members")); + const body = await res.json(); - const req = new Request("http://localhost/api/members"); - const res: Response = await GET(req); - const data = await res.json(); - - assert.ok(Array.isArray(data), "response should be an array"); - assert.strictEqual(data.length, mockMembers.length); + assert.strictEqual(body.ok, true); + assert.ok(Array.isArray(body.data.items), "response should include items"); + assert.strictEqual(body.data.total, mockMembers.length); } finally { - if (previousMode === undefined) { - delete process.env.DASHBOARD_API_MODE; - } else { - process.env.DASHBOARD_API_MODE = previousMode; - } + restoreEnv("DASHBOARD_API_MODE", previousMode); } }); -// ──────────────────────────────────────────────────────────────────────────── -// api-helpers -// ──────────────────────────────────────────────────────────────────────────── - -test("apiUnsupported returns 501 with UNSUPPORTED_IN_LIVE_MODE code", async () => { +test("apiUnsupported returns the shared unsupported shape", async () => { const { apiUnsupported } = await import("../lib/api-helpers.js"); - - const res: Response = apiUnsupported("Test message"); - const data = await res.json(); + const res: Response = apiUnsupported("example.feature", "live", "Test message"); + const body = await res.json(); assert.strictEqual(res.status, 501); - assert.strictEqual(data.code, "UNSUPPORTED_IN_LIVE_MODE"); - assert.strictEqual(data.error, "Test message"); + assert.strictEqual(body.ok, false); + assert.strictEqual(body.code, "UNSUPPORTED"); + assert.strictEqual(body.error, "Test message"); + assert.deepStrictEqual(body.unsupported, { feature: "example.feature", mode: "live" }); }); + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +}