From 1dd07f490bac8e50d4da07c7571f6f1f721e83db Mon Sep 17 00:00:00 2001 From: IHUOMA NKECHI BRIDGET Date: Wed, 29 Jul 2026 17:32:26 +0100 Subject: [PATCH] Add member search and join date filters --- apps/dashboard/app/api/members/route.ts | 2 + apps/dashboard/app/members/page.tsx | 96 ++++++- apps/dashboard/lib/pagination.ts | 22 +- .../lib/repositories/adapters/durable.ts | 20 ++ apps/dashboard/lib/repositories/types.ts | 4 + .../test/management-pagination.test.ts | 235 ++++++++++++++++++ 6 files changed, 372 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/app/api/members/route.ts b/apps/dashboard/app/api/members/route.ts index 8c02bed..832d8c0 100644 --- a/apps/dashboard/app/api/members/route.ts +++ b/apps/dashboard/app/api/members/route.ts @@ -126,6 +126,8 @@ function parseMemberListQuery(request: Request): MemberListQuery { search: searchParams.get("search") ?? undefined, status: isMemberStatus(status) ? status : "all", role: role && isMemberRole(role) ? role : "all", + joinedFrom: searchParams.get("joinedFrom") ?? undefined, + joinedTo: searchParams.get("joinedTo") ?? undefined, limit: parseListLimit(searchParams.get("limit")), page: parseListPage(searchParams.get("page")), cursor: searchParams.get("cursor"), diff --git a/apps/dashboard/app/members/page.tsx b/apps/dashboard/app/members/page.tsx index dfd0bee..bd2df8c 100644 --- a/apps/dashboard/app/members/page.tsx +++ b/apps/dashboard/app/members/page.tsx @@ -61,6 +61,10 @@ function readPageFilter(value: string | null): number { return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 1; } +function readDateFilter(value: string | null): string { + return value && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : ""; +} + function MembersPageContent() { const session = useSession(); const canWrite = canManageMembers(session, session.activeGuildId); @@ -83,6 +87,8 @@ function MembersPageContent() { const [search, setSearch] = useState(() => searchParams.get("search") ?? ""); const [status, setStatus] = useState(() => readStatusFilter(searchParams.get("status"))); const [role, setRole] = useState(() => readRoleFilter(searchParams.get("role"))); + const [joinedFrom, setJoinedFrom] = useState(() => readDateFilter(searchParams.get("joinedFrom"))); + const [joinedTo, setJoinedTo] = useState(() => readDateFilter(searchParams.get("joinedTo"))); const [page, setPage] = useState(() => readPageFilter(searchParams.get("page"))); const debouncedSearch = useDebouncedValue(search, 250); const previousMembersRef = useRef(members); @@ -93,8 +99,10 @@ function MembersPageContent() { search: debouncedSearch.trim() || undefined, status: status === "all" ? undefined : status, role: role === "all" ? undefined : role, + joinedFrom: joinedFrom || undefined, + joinedTo: joinedTo || undefined, }), - [debouncedSearch, guildId, page, role, status] + [debouncedSearch, guildId, joinedFrom, joinedTo, page, role, status] ); const cacheRevision = useQueryInvalidation(membersQueryKey); @@ -103,7 +111,15 @@ function MembersPageContent() { const [form, setForm] = useState({ name: "", wallet: "" }); const updateFilterQuery = useCallback( - (updates: { search?: string; status?: MemberStatusFilter; role?: MemberRoleFilter; guild?: string; page?: number | null }) => { + (updates: { + search?: string; + status?: MemberStatusFilter; + role?: MemberRoleFilter; + joinedFrom?: string; + joinedTo?: string; + guild?: string; + page?: number | null; + }) => { const next = new URLSearchParams(searchParams.toString()); if (updates.search !== undefined) { @@ -128,6 +144,20 @@ function MembersPageContent() { next.set("role", updates.role); } } + if (updates.joinedFrom !== undefined) { + if (updates.joinedFrom) { + next.set("joinedFrom", updates.joinedFrom); + } else { + next.delete("joinedFrom"); + } + } + if (updates.joinedTo !== undefined) { + if (updates.joinedTo) { + next.set("joinedTo", updates.joinedTo); + } else { + next.delete("joinedTo"); + } + } if (updates.guild !== undefined) { if (updates.guild) { next.set("guild", updates.guild); @@ -167,6 +197,18 @@ function MembersPageContent() { updateFilterQuery({ role: value, page: null }); }; + const handleJoinedFromChange = (value: string) => { + setJoinedFrom(value); + setPage(1); + updateFilterQuery({ joinedFrom: value, page: null }); + }; + + const handleJoinedToChange = (value: string) => { + setJoinedTo(value); + setPage(1); + updateFilterQuery({ joinedTo: value, page: null }); + }; + const handleGuildFilterChange = (value: string) => { setGuildId(value); setPage(1); @@ -183,20 +225,24 @@ function MembersPageContent() { const nextSearch = searchParams.get("search") ?? ""; const nextStatus = readStatusFilter(searchParams.get("status")); const nextRole = readRoleFilter(searchParams.get("role")); + const nextJoinedFrom = readDateFilter(searchParams.get("joinedFrom")); + const nextJoinedTo = readDateFilter(searchParams.get("joinedTo")); const nextPage = readPageFilter(searchParams.get("page")); const nextGuildId = searchParams.get("guild"); if (nextSearch !== search) setSearch(nextSearch); if (nextStatus !== status) setStatus(nextStatus); if (nextRole !== role) setRole(nextRole); + if (nextJoinedFrom !== joinedFrom) setJoinedFrom(nextJoinedFrom); + if (nextJoinedTo !== joinedTo) setJoinedTo(nextJoinedTo); if (nextPage !== page) setPage(nextPage); if (nextGuildId && nextGuildId !== guildId && guilds.some((candidate) => candidate.id === nextGuildId)) { setGuildId(nextGuildId); } - }, [guildId, guilds, page, role, search, searchParams, setGuildId, status]); + }, [guildId, guilds, joinedFrom, joinedTo, page, role, search, searchParams, setGuildId, status]); useEffect(() => { setPage(1); - }, [debouncedSearch, role, status, guildId]); + }, [debouncedSearch, role, status, joinedFrom, joinedTo, guildId]); // Drop previous tenant's rows immediately so the UI never shows stale data. useEffect(() => { @@ -224,6 +270,8 @@ function MembersPageContent() { if (debouncedSearch.trim()) params.set("search", debouncedSearch.trim()); if (status !== "all") params.set("status", status); if (role !== "all") params.set("role", role); + if (joinedFrom) params.set("joinedFrom", joinedFrom); + if (joinedTo) params.set("joinedTo", joinedTo); const data = await dashboardQueryCache.fetchQuery(membersQueryKey, async () => { const res = await guildFetch(`/api/members?${params.toString()}`, guildId); @@ -251,7 +299,7 @@ function MembersPageContent() { return () => { mounted = false; }; - }, [apiMode, cacheRevision, debouncedSearch, guildId, membersQueryKey, page, role, status]); + }, [apiMode, cacheRevision, debouncedSearch, guildId, joinedFrom, joinedTo, membersQueryKey, page, role, status]); const updateMutation = useOptimisticMutation & { version?: number } }>({ mutationFn: async ({ id, data }) => { @@ -468,6 +516,42 @@ function MembersPageContent() { ))} + + {/* Join-date range row */} +
+ + + {(joinedFrom || joinedTo) && ( + + )} +
)} @@ -569,7 +653,7 @@ function MembersPageContent() { {members.length === 0 && (
- +
)} diff --git a/apps/dashboard/lib/pagination.ts b/apps/dashboard/lib/pagination.ts index d44b49b..c419b9f 100644 --- a/apps/dashboard/lib/pagination.ts +++ b/apps/dashboard/lib/pagination.ts @@ -55,6 +55,9 @@ export function filterPasses(passes: Pass[], query: PassListQuery = {}): Pass[] export function filterMembers(members: Member[], query: MemberListQuery = {}): Member[] { const search = normaliseSearch(query.search); + const joinedFrom = parseDateBoundary(query.joinedFrom, "start"); + const joinedTo = parseDateBoundary(query.joinedTo, "end"); + return members.filter((member) => { const matchesSearch = !search || @@ -64,11 +67,28 @@ export function filterMembers(members: Member[], query: MemberListQuery = {}): M !query.status || query.status === "all" || member.status === query.status; const matchesRole = !query.role || query.role === "all" || member.roles.includes(query.role); + const joinedAtTime = new Date(member.joinedAt).getTime(); + const matchesJoinedFrom = joinedFrom === null || joinedAtTime >= joinedFrom; + const matchesJoinedTo = joinedTo === null || joinedAtTime <= joinedTo; - return matchesSearch && matchesStatus && matchesRole; + return matchesSearch && matchesStatus && matchesRole && matchesJoinedFrom && matchesJoinedTo; }); } +/** + * Resolves a `YYYY-MM-DD` (or full ISO) date-range bound to an epoch ms + * timestamp. Date-only strings are widened to the start/end of that day so a + * `joinedTo` filter includes every member who joined on that calendar date, + * not just at midnight. Invalid/missing input yields `null` (no bound). + */ +export function parseDateBoundary(value: string | undefined, edge: "start" | "end"): number | null { + if (!value) return null; + const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(value); + const iso = isDateOnly ? `${value}T${edge === "start" ? "00:00:00.000" : "23:59:59.999"}Z` : value; + const time = new Date(iso).getTime(); + return Number.isNaN(time) ? null : time; +} + export function parseListLimit(value: string | null): number | undefined { if (!value) return undefined; return clampPositiveInteger(Number(value), DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); diff --git a/apps/dashboard/lib/repositories/adapters/durable.ts b/apps/dashboard/lib/repositories/adapters/durable.ts index b815c33..9378117 100644 --- a/apps/dashboard/lib/repositories/adapters/durable.ts +++ b/apps/dashboard/lib/repositories/adapters/durable.ts @@ -805,6 +805,26 @@ export class DurableMemberRepository paramIdx++; } + if (options.joinedFrom) { + const { parseDateBoundary } = await import("@/lib/pagination"); + const from = parseDateBoundary(options.joinedFrom, "start"); + if (from !== null) { + conditions.push(`joined_at >= $${paramIdx}`); + params.push(new Date(from).toISOString()); + paramIdx++; + } + } + + if (options.joinedTo) { + const { parseDateBoundary } = await import("@/lib/pagination"); + const to = parseDateBoundary(options.joinedTo, "end"); + if (to !== null) { + conditions.push(`joined_at <= $${paramIdx}`); + params.push(new Date(to).toISOString()); + paramIdx++; + } + } + const where = conditions.join(" AND "); const countResult = await query( `SELECT COUNT(*)::integer as count FROM members WHERE ${where}`, diff --git a/apps/dashboard/lib/repositories/types.ts b/apps/dashboard/lib/repositories/types.ts index f40acb3..40800ee 100644 --- a/apps/dashboard/lib/repositories/types.ts +++ b/apps/dashboard/lib/repositories/types.ts @@ -32,6 +32,10 @@ export interface MemberListQuery extends PaginationOptions { search?: string; status?: Member["status"] | "all"; role?: string | "all"; + /** Inclusive lower bound on `joinedAt`, as a `YYYY-MM-DD` date or ISO timestamp. */ + joinedFrom?: string; + /** Inclusive upper bound on `joinedAt`, as a `YYYY-MM-DD` date or ISO timestamp. */ + joinedTo?: string; } /** diff --git a/apps/dashboard/test/management-pagination.test.ts b/apps/dashboard/test/management-pagination.test.ts index e31756a..1e90204 100644 --- a/apps/dashboard/test/management-pagination.test.ts +++ b/apps/dashboard/test/management-pagination.test.ts @@ -3,9 +3,11 @@ import assert from "node:assert/strict"; import { DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, + filterMembers, normalisePagination, paginateItems, } from "../lib/pagination"; +import type { Member } from "../lib/mock-data"; describe("pagination boundary helpers", () => { test("normalisePagination falls back for zero and negative limits", () => { @@ -54,6 +56,90 @@ describe("pagination boundary helpers", () => { }); }); +const testMembers: Member[] = [ + { + id: "m1", + guildId: "g1", + version: 1, + wallet: "0xAAA1111111111111111111111111111111111A", + name: "Alice Anderson", + status: "active", + roles: ["admin"], + joinedAt: "2025-01-10T00:00:00Z", + lastActive: "2025-06-01T00:00:00Z", + }, + { + id: "m2", + guildId: "g1", + version: 1, + wallet: "0xBBB2222222222222222222222222222222222B", + name: "Bob Brown", + status: "active", + roles: ["member"], + joinedAt: "2025-03-15T00:00:00Z", + lastActive: "2025-06-01T00:00:00Z", + }, + { + id: "m3", + guildId: "g1", + version: 1, + wallet: "0xCCC3333333333333333333333333333333333C", + name: "Cara White", + status: "inactive", + roles: ["member", "contributor"], + joinedAt: "2025-06-20T00:00:00Z", + lastActive: "2025-06-01T00:00:00Z", + }, +]; + +describe("filterMembers (search, role, and join-date filtering)", () => { + test("matches partial member name, case-insensitively", () => { + const result = filterMembers(testMembers, { search: "ali" }); + assert.deepEqual(result.map((m) => m.name), ["Alice Anderson"]); + }); + + test("matches partial wallet address, case-insensitively", () => { + const result = filterMembers(testMembers, { search: "ccc333" }); + assert.deepEqual(result.map((m) => m.name), ["Cara White"]); + }); + + test("filters by role", () => { + const result = filterMembers(testMembers, { role: "contributor" }); + assert.deepEqual(result.map((m) => m.name), ["Cara White"]); + }); + + test("filters by joined-date range, inclusive of both bounds", () => { + const result = filterMembers(testMembers, { joinedFrom: "2025-01-10", joinedTo: "2025-03-15" }); + assert.deepEqual(result.map((m) => m.name), ["Alice Anderson", "Bob Brown"]); + }); + + test("joinedTo alone excludes members who joined after that date", () => { + const result = filterMembers(testMembers, { joinedTo: "2025-02-01" }); + assert.deepEqual(result.map((m) => m.name), ["Alice Anderson"]); + }); + + test("joinedFrom alone excludes members who joined before that date", () => { + const result = filterMembers(testMembers, { joinedFrom: "2025-06-01" }); + assert.deepEqual(result.map((m) => m.name), ["Cara White"]); + }); + + test("combines search, role, status, and date range together", () => { + const result = filterMembers(testMembers, { + search: "bob", + role: "member", + status: "active", + joinedFrom: "2025-03-01", + joinedTo: "2025-04-01", + }); + assert.deepEqual(result.map((m) => m.name), ["Bob Brown"]); + }); + + test("returns an empty array when combined filters match nobody", () => { + const result = filterMembers(testMembers, { search: "bob", role: "contributor" }); + assert.deepEqual(result, []); + }); +}); + describe("management list pagination and filtering", () => { test("GET /api/passes searches by name or description", async () => { const previousMode = process.env.DASHBOARD_API_MODE; @@ -143,6 +229,155 @@ describe("management list pagination and filtering", () => { restoreEnv("DASHBOARD_API_MODE", previousMode); } }); + + test("GET /api/members search matches partial member name case-insensitively", 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=AlI")); + const body = await response.json(); + + assert.equal(body.ok, true); + assert.equal(body.data.total, 1); + assert.equal(body.data.items[0].name, "Alice"); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); + + test("GET /api/members search matches partial wallet address case-insensitively", 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=90f8bf6a")); + 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 role alone", 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?role=admin")); + const body = await response.json(); + + assert.equal(body.ok, true); + // Guild 1 (default) has exactly one admin: Alice. + assert.equal(body.data.total, 1); + assert.equal(body.data.items[0].name, "Alice"); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); + + test("GET /api/members filters by joined-date range", async () => { + const previousMode = process.env.DASHBOARD_API_MODE; + process.env.DASHBOARD_API_MODE = "mock"; + + try { + const { GET } = await import("../app/api/members/route.js"); + + // Guild 1 joinedAt dates: Alice 2024-12-01, Bob 2025-01-05, + // Charlie 2025-06-12, Diana 2025-02-14. + const response = await GET( + new Request("http://localhost/api/members?joinedFrom=2025-01-01&joinedTo=2025-03-01") + ); + const body = await response.json(); + + assert.equal(body.ok, true); + assert.equal(body.data.total, 2); + const names = body.data.items.map((m: { name: string }) => m.name).sort(); + assert.deepEqual(names, ["Bob", "Diana"]); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); + + test("GET /api/members joinedTo is inclusive of the whole day", async () => { + const previousMode = process.env.DASHBOARD_API_MODE; + process.env.DASHBOARD_API_MODE = "mock"; + + try { + const { GET } = await import("../app/api/members/route.js"); + + // Charlie joined 2025-06-12T00:00:00Z; a joinedTo of the same calendar + // date must still include him even though his timestamp isn't midnight-exact. + const response = await GET(new Request("http://localhost/api/members?joinedTo=2025-06-12")); + const body = await response.json(); + + assert.equal(body.ok, true); + assert.ok(body.data.items.some((m: { name: string }) => m.name === "Charlie")); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); + + test("GET /api/members combines search, role, status, and date range", 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 { GUILD_ID_HEADER } = await import("../lib/guild-context.js"); + + // Guild 2: Frank (contributor, active, joined 2025-02-03) should match; + // narrowing the date range further should exclude him again. + const matches = new Request( + "http://localhost/api/members?search=frank&role=contributor&status=active&joinedFrom=2025-02-01&joinedTo=2025-02-28", + { headers: { [GUILD_ID_HEADER]: "2" } } + ); + const matchesBody = await (await GET(matches as any)).json(); + assert.equal(matchesBody.ok, true); + assert.equal(matchesBody.data.total, 1); + assert.equal(matchesBody.data.items[0].name, "Frank"); + + const excluded = new Request( + "http://localhost/api/members?search=frank&role=contributor&status=active&joinedFrom=2025-03-01&joinedTo=2025-03-31", + { headers: { [GUILD_ID_HEADER]: "2" } } + ); + const excludedBody = await (await GET(excluded as any)).json(); + assert.equal(excludedBody.ok, true); + assert.equal(excludedBody.data.total, 0); + assert.deepEqual(excludedBody.data.items, []); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); + + test("GET /api/members returns empty state when combined filters match nobody", async () => { + const previousMode = process.env.DASHBOARD_API_MODE; + process.env.DASHBOARD_API_MODE = "mock"; + + try { + const { GET } = await import("../app/api/members/route.js"); + + // Real name + wrong role: no member in guild 1 satisfies both. + const response = await GET( + new Request("http://localhost/api/members?search=alice&role=contributor") + ); + 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.hasNextPage, false); + assert.equal(body.data.hasPreviousPage, false); + } finally { + restoreEnv("DASHBOARD_API_MODE", previousMode); + } + }); }); function restoreEnv(name: string, value: string | undefined) {