diff --git a/README.md b/README.md index 3f0fa37..6386c2e 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ The GuildPass Access API follows a strict versioning and compatibility contract | GET | `/v1/memberships/:wallet` | Membership status summary by wallet | | GET | `/v1/members/:wallet` | Member profile (with membership and roles) | | POST | `/v1/access/check` | Access decision for `{ wallet, communityId, resource }` (schema-validated; per-IP/API-key and per-wallet rate limits return `429` + `Retry-After`) | -| GET | `/v1/communities/:communityId/members` | Admin member listing (cursor pagination: `limit` default 50 / max 200, `cursor`, optional `role` / `status`) | +| GET | `/v1/communities/:communityId/members` | Admin member listing (offset pagination: `page` default 1, `pageSize` default 25 / max 100, `sort` `joinedAt`\|`role`, optional `role` / `status`) | Responses include `allowed`/`denied` plus human-readable and machine-readable reasons. diff --git a/apps/access-api/src/routes.ts b/apps/access-api/src/routes.ts index 45d061d..18b2d84 100644 --- a/apps/access-api/src/routes.ts +++ b/apps/access-api/src/routes.ts @@ -124,20 +124,21 @@ function getRequesterWallet(request: FastifyRequest): string { return ""; } -/** Max page size for GET /v1/communities/:communityId/members (issue #236). */ -export const MEMBERS_LIST_MAX_LIMIT = 200; -export const MEMBERS_LIST_DEFAULT_LIMIT = 50; +/** Page size bounds for GET /v1/communities/:communityId/members (issue #259). */ +export const MEMBERS_LIST_MAX_PAGE_SIZE = 100; +export const MEMBERS_LIST_DEFAULT_PAGE_SIZE = 25; const memberListQuerySchema = z.object({ role: z.enum(["admin", "member", "contributor"]).optional(), status: z.enum(["invited", "active", "expired", "suspended"]).optional(), - limit: z.coerce + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce .number() .int() .min(1) - .max(MEMBERS_LIST_MAX_LIMIT) - .default(MEMBERS_LIST_DEFAULT_LIMIT), - cursor: z.string().min(1).optional(), + .max(MEMBERS_LIST_MAX_PAGE_SIZE) + .default(MEMBERS_LIST_DEFAULT_PAGE_SIZE), + sort: z.enum(["joinedAt", "role"]).default("joinedAt"), }); function sendRoleMutationError(reply: FastifyReply, error: unknown) { @@ -1215,7 +1216,9 @@ export async function registerRoutes(app: FastifyInstance): Promise { return memberService.isCommunityAdmin(communityId, requesterWallet); } - // GET /v1/communities/:communityId/members — cursor-paginated admin listing (#236) + // GET /v1/communities/:communityId/members — offset-paginated, sortable admin + // listing (#259). Returns the shared { data, total, page, pageSize, + // nextCursor } envelope; supersedes the cursor-based listing from #236. app.get( "/v1/communities/:communityId/members", { @@ -1235,17 +1238,19 @@ export async function registerRoutes(app: FastifyInstance): Promise { ), ); } - const { role, status, limit, cursor } = parsedQuery.data; + const { role, status, page, pageSize, sort } = parsedQuery.data; const requesterWallet = getRequesterWallet(request); try { if (!(await requireCommunityAdmin(communityId, requesterWallet))) { return reply.status(403).send(forbidden("Forbidden")); } - const result = await memberService.listMembersForAdmin( - communityId, + const result = await memberService.listMembersForAdmin(communityId, { role, - { limit, cursor, status }, - ); + status, + page, + pageSize, + sort, + }); return reply.status(200).send(result); } catch (error) { if (error instanceof MemberServiceError) { diff --git a/apps/access-api/src/schemas.ts b/apps/access-api/src/schemas.ts index 52ad65b..9fe45fd 100644 --- a/apps/access-api/src/schemas.ts +++ b/apps/access-api/src/schemas.ts @@ -1008,30 +1008,36 @@ export const listCommunityMembersSchema = { enum: membershipStateEnum, description: "Filter members by membership status", }, - limit: { + page: { + type: "integer", + minimum: 1, + default: 1, + description: "1-based page number. Values below 1 return 400.", + }, + pageSize: { type: "integer", minimum: 1, - maximum: 200, - default: 50, + maximum: 100, + default: 25, description: - "Page size (default 50, maximum 200). Requests above 200 return 400.", + "Page size (default 25, maximum 100). Requests above 100 return 400.", }, - cursor: { + sort: { type: "string", - minLength: 1, + enum: ["joinedAt", "role"], + default: "joinedAt", description: - "Opaque cursor from a previous response's pagination.nextCursor", + "Sort field. A stable id ASC tiebreaker is always applied so pages never overlap.", }, }, }, response: { 200: { - description: "Cursor-paginated member list", + description: "Offset-paginated member list", type: "object", - required: ["communityId", "members", "pagination"], + required: ["data", "total", "page", "pageSize", "nextCursor"], properties: { - communityId: { type: "string" }, - members: { + data: { type: "array", items: { type: "object", @@ -1043,48 +1049,48 @@ export const listCommunityMembersSchema = { type: "array", items: { type: "string", enum: roleEnum }, }, + joinedAt: { type: "string", format: "date-time" }, }, }, }, - pagination: { - type: "object", - required: ["limit", "hasMore", "nextCursor"], - properties: { - limit: { type: "integer" }, - hasMore: { type: "boolean" }, - nextCursor: { - type: "string", - nullable: true, - description: "Pass as ?cursor= on the next request when hasMore", - }, - }, + total: { + type: "integer", + description: "Total members matching the filters, across all pages", + }, + page: { type: "integer" }, + pageSize: { type: "integer" }, + nextCursor: { + type: "string", + nullable: true, + description: + "Always null in offset mode; reserved for a future move to cursor pagination", }, }, example: { - communityId: "community-mainnet-42", - members: [ + data: [ { wallet: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", displayName: "alice.eth", state: "active", roles: ["admin", "member"], + joinedAt: "2026-01-01T00:00:00.000Z", }, { wallet: "0xabcd1234567890abcd1234567890abcd12345678", displayName: null, state: "active", roles: ["member"], + joinedAt: "2026-01-02T00:00:00.000Z", }, ], - pagination: { - limit: 50, - hasMore: false, - nextCursor: null, - }, + total: 2, + page: 1, + pageSize: 25, + nextCursor: null, }, }, 400: { - description: "Invalid query parameters (e.g. limit above 200)", + description: "Invalid query parameters (e.g. pageSize above 100)", ...errorSchema, example: { error: "VALIDATION_ERROR", diff --git a/apps/access-api/src/services/memberService.test.ts b/apps/access-api/src/services/memberService.test.ts index 371f95a..32cf753 100644 --- a/apps/access-api/src/services/memberService.test.ts +++ b/apps/access-api/src/services/memberService.test.ts @@ -520,12 +520,17 @@ describe("getMemberService - Membership State Normalization", () => { }); describe("listMembersForAdmin", () => { - test("should return members with normalized state", async () => { + // Issue #259: offset pagination (page/pageSize) + sort (joinedAt|role), + // returning the { data, total, page, pageSize, nextCursor } envelope. + test("returns paginated envelope with normalized state and defaults", async () => { const pastDate = new Date(Date.now() - 86400000); const futureDate = new Date(Date.now() + 86400000); + const createdA = new Date("2026-01-01T00:00:00.000Z"); + const createdB = new Date("2026-01-02T00:00:00.000Z"); const mockMembers = [ { id: "member-1", + createdAt: createdA, wallet: { address: "0x1111111111111111" }, profile: { displayName: "Member 1" }, membership: { @@ -536,6 +541,7 @@ describe("getMemberService - Membership State Normalization", () => { }, { id: "member-2", + createdAt: createdB, wallet: { address: "0x2222222222222222" }, profile: { displayName: "Member 2" }, membership: { @@ -547,23 +553,35 @@ describe("getMemberService - Membership State Normalization", () => { ]; (mockPrisma.member.findMany as jest.Mock).mockResolvedValue(mockMembers); + (mockPrisma.member.count as jest.Mock).mockResolvedValue(2); const result = await memberService.listMembersForAdmin("community-1"); - expect(result.members).toHaveLength(2); - expect(result.members[0].state).toBe("expired"); - expect(result.members[1].state).toBe("active"); - expect(result.pagination).toEqual({ - limit: 50, - hasMore: false, + expect(result.data).toHaveLength(2); + expect(result.data[0].state).toBe("expired"); + expect(result.data[1].state).toBe("active"); + expect(result.data[0].joinedAt).toBe(createdA.toISOString()); + expect(result).toMatchObject({ + total: 2, + page: 1, + pageSize: 25, nextCursor: null, }); + // Default sort=joinedAt → createdAt asc with id asc tiebreaker; page 1. + expect(mockPrisma.member.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + skip: 0, + take: 25, + }), + ); }); - test("should filter members by role", async () => { + test("filters members by role", async () => { const mockMembers = [ { id: "member-1", + createdAt: new Date("2026-01-01T00:00:00.000Z"), wallet: { address: "0x1111111111111111" }, profile: { displayName: "Admin" }, membership: { state: "active" as MembershipState, expiresAt: null }, @@ -572,14 +590,14 @@ describe("getMemberService - Membership State Normalization", () => { ]; (mockPrisma.member.findMany as jest.Mock).mockResolvedValue(mockMembers); + (mockPrisma.member.count as jest.Mock).mockResolvedValue(1); - const result = await memberService.listMembersForAdmin( - "community-1", - "admin", - ); + const result = await memberService.listMembersForAdmin("community-1", { + role: "admin", + }); - expect(result.members).toHaveLength(1); - expect(result.members[0].wallet).toBe("0x1111111111111111"); + expect(result.data).toHaveLength(1); + expect(result.data[0].wallet).toBe("0x1111111111111111"); expect(mockPrisma.member.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ @@ -590,103 +608,111 @@ describe("getMemberService - Membership State Normalization", () => { ); }); - test("should filter members by status (active)", async () => { - const futureDate = new Date(Date.now() + 86400000); - const pastDate = new Date(Date.now() - 86400000); - const mockMembers = [ - { - id: "member-1", - wallet: { address: "0x1111111111111111" }, - profile: { displayName: "Active Member" }, - membership: { - state: "active" as MembershipState, - expiresAt: futureDate, - }, - roles: [], - }, - { - id: "member-2", - wallet: { address: "0x2222222222222222" }, - profile: { displayName: "Expired Member" }, - membership: { - state: "active" as MembershipState, - expiresAt: pastDate, - }, - roles: [], - }, - ]; + test("filters by status in SQL so pagination is not short-changed", async () => { + (mockPrisma.member.findMany as jest.Mock).mockResolvedValue([]); + (mockPrisma.member.count as jest.Mock).mockResolvedValue(0); - (mockPrisma.member.findMany as jest.Mock).mockResolvedValue(mockMembers); + await memberService.listMembersForAdmin("community-1", { + status: "active", + }); - const result = await memberService.listMembersForAdmin( - "community-1", - undefined, - { status: "active" }, + expect(mockPrisma.member.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + communityId: "community-1", + membership: { + state: "active", + OR: [ + { expiresAt: null }, + { expiresAt: { gte: expect.any(Date) } }, + ], + }, + }), + }), ); + }); - expect(result.members).toHaveLength(1); - expect(result.members[0].wallet).toBe("0x1111111111111111"); - expect(result.members[0].state).toBe("active"); + test("status=expired matches explicit and past-expiry memberships", async () => { + (mockPrisma.member.findMany as jest.Mock).mockResolvedValue([]); + (mockPrisma.member.count as jest.Mock).mockResolvedValue(0); + + await memberService.listMembersForAdmin("community-1", { + status: "expired", + }); + + expect(mockPrisma.member.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + membership: { + OR: [ + { state: "expired" }, + { expiresAt: { lt: expect.any(Date) } }, + ], + }, + }), + }), + ); }); - test("should filter members by status (expired)", async () => { - const pastDate = new Date(Date.now() - 86400000); - const futureDate = new Date(Date.now() + 86400000); - const mockMembers = [ - { - id: "member-1", - wallet: { address: "0x1111111111111111" }, - profile: { displayName: "Expired Member" }, - membership: { - state: "active" as MembershipState, - expiresAt: pastDate, - }, - roles: [], - }, - { - id: "member-2", - wallet: { address: "0x2222222222222222" }, - profile: { displayName: "Active Member" }, - membership: { - state: "active" as MembershipState, - expiresAt: futureDate, - }, - roles: [], - }, - ]; + test("paginates with page/pageSize (skip/take)", async () => { + (mockPrisma.member.findMany as jest.Mock).mockResolvedValue([]); + (mockPrisma.member.count as jest.Mock).mockResolvedValue(5); - (mockPrisma.member.findMany as jest.Mock).mockResolvedValue(mockMembers); + const result = await memberService.listMembersForAdmin("community-1", { + page: 2, + pageSize: 10, + }); - const result = await memberService.listMembersForAdmin( - "community-1", - undefined, - { status: "expired" }, + expect(mockPrisma.member.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 10, + take: 10, + }), ); + expect(result).toMatchObject({ + total: 5, + page: 2, + pageSize: 10, + nextCursor: null, + }); + }); + + test("rejects pageSize > 100 with a 400", async () => { + await expect( + memberService.listMembersForAdmin("community-1", { pageSize: 200 }), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(mockPrisma.member.findMany).not.toHaveBeenCalled(); + }); + + test("rejects pageSize < 1 with a 400", async () => { + await expect( + memberService.listMembersForAdmin("community-1", { pageSize: 0 }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); - expect(result.members).toHaveLength(1); - expect(result.members[0].wallet).toBe("0x1111111111111111"); - expect(result.members[0].state).toBe("expired"); + test("rejects page < 1 with a 400", async () => { + await expect( + memberService.listMembersForAdmin("community-1", { page: 0 }), + ).rejects.toMatchObject({ statusCode: 400 }); }); - test("should clamp limit to 200", async () => { + test("sort=role orders by role count with id ASC tiebreaker", async () => { (mockPrisma.member.findMany as jest.Mock).mockResolvedValue([]); - const result = await memberService.listMembersForAdmin( - "community-1", - undefined, - { limit: 500 }, - ); + await memberService.listMembersForAdmin("community-1", { sort: "role" }); expect(mockPrisma.member.findMany).toHaveBeenCalledWith( - expect.objectContaining({ take: 201 }), + expect.objectContaining({ + orderBy: [{ roles: { _count: "desc" } }, { id: "asc" }], + }), ); - expect(result.pagination.limit).toBe(200); }); - test("should only include active roles", async () => { + test("only includes active roles", async () => { const mockMembers = [ { id: "member-1", + createdAt: new Date("2026-01-01T00:00:00.000Z"), wallet: { address: "0x1111111111111111" }, profile: { displayName: "User" }, membership: { state: "active" as MembershipState, expiresAt: null }, @@ -701,73 +727,21 @@ describe("getMemberService - Membership State Normalization", () => { const result = await memberService.listMembersForAdmin("community-1"); - expect(result.members[0].roles).toEqual(["admin"]); + expect(result.data[0].roles).toEqual(["admin"]); }); - test("should apply default pagination and return end-of-results metadata", async () => { + test("empty tail page returns data:[] with the correct total", async () => { (mockPrisma.member.findMany as jest.Mock).mockResolvedValue([]); + (mockPrisma.member.count as jest.Mock).mockResolvedValue(42); - const result = await memberService.listMembersForAdmin("community-1"); - - expect(mockPrisma.member.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { communityId: "community-1" }, - orderBy: { id: "asc" }, - take: 51, - }), - ); - expect(result.pagination).toEqual({ - limit: 50, - hasMore: false, - nextCursor: null, + const result = await memberService.listMembersForAdmin("community-1", { + page: 9999, + pageSize: 25, }); - }); - - test("should return next cursor when another page exists", async () => { - const mockMembers = [ - { - id: "member-1", - wallet: { address: "0x111" }, - profile: null, - membership: null, - roles: [], - }, - { - id: "member-2", - wallet: { address: "0x222" }, - profile: null, - membership: null, - roles: [], - }, - { - id: "member-3", - wallet: { address: "0x333" }, - profile: null, - membership: null, - roles: [], - }, - ]; - (mockPrisma.member.findMany as jest.Mock).mockResolvedValue(mockMembers); - const result = await memberService.listMembersForAdmin( - "community-1", - undefined, - { limit: 2, cursor: "member-0" }, - ); - - expect(mockPrisma.member.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - cursor: { id: "member-0" }, - skip: 1, - take: 3, - }), - ); - expect(result.members).toHaveLength(2); - expect(result.pagination).toEqual({ - limit: 2, - hasMore: true, - nextCursor: "member-2", - }); + expect(result.data).toHaveLength(0); + expect(result.total).toBe(42); + expect(result.page).toBe(9999); }); }); diff --git a/apps/access-api/src/services/memberService.ts b/apps/access-api/src/services/memberService.ts index ad62e87..e41dd9d 100644 --- a/apps/access-api/src/services/memberService.ts +++ b/apps/access-api/src/services/memberService.ts @@ -19,7 +19,7 @@ import { WalletAddress, RoleDefinition, DelegatedGrant, - MembershipState, + PaginatedResponse, } from "@guildpass/shared-types"; import { createDefaultEngine, @@ -765,6 +765,140 @@ export function getMemberService( throw { statusCode: 403, message: "Not authorized" }; } + // --- Offset-paginated + sorted member listing for admins (#259) --- + // Returns the shared PaginatedResponse envelope: { data, total, page, + // pageSize, nextCursor }. `nextCursor` stays null in offset mode and is + // reserved for a future migration to cursor-based pagination. + async function listMembersForAdmin( + communityId: string, + options: { + role?: Role; + status?: string; + page?: number; + pageSize?: number; + sort?: "joinedAt" | "role"; + } = {}, + ): Promise< + PaginatedResponse<{ + wallet: string; + displayName: string | null; + state: string; + roles: string[]; + joinedAt: string; + }> + > { + const { role, status, page = 1, pageSize = 25, sort = "joinedAt" } = options; + + // Validate explicitly: reject out-of-range values with 400 instead of + // clamping silently, so admins get honest feedback. + if (pageSize < 1 || pageSize > 100) { + throw new MemberServiceError("pageSize must be between 1 and 100", 400); + } + if (page < 1) { + throw new MemberServiceError("page must be >= 1", 400); + } + + const safePageSize = pageSize; + const skip = (page - 1) * safePageSize; + + // Build Prisma where clause + const where: Prisma.MemberWhereInput = { communityId }; + + // Role filter (via roles relation) + if (role) { + where.roles = { + some: { + role, + active: true, + }, + }; + } + + // Status filter. This mirrors getNormalizedMembershipState() — the same + // rule that produces the `state` field below — but expressed as SQL so the + // filter is applied *before* skip/take. Filtering in JS after the page was + // fetched (the previous behaviour) silently returned short pages. + if (status) { + const now = new Date(); + switch (status) { + case "active": + where.membership = { + state: "active", + OR: [{ expiresAt: null }, { expiresAt: { gte: now } }], + }; + break; + case "suspended": + where.membership = { state: "suspended" }; + break; + case "expired": + // Either explicitly expired, or past its expiry while still marked active. + where.membership = { + OR: [{ state: "expired" }, { expiresAt: { lt: now } }], + }; + break; + case "invited": + where.OR = [{ membership: { is: null } }, { membership: { state: "invited" } }]; + break; + default: + // ignore unknown status + break; + } + } + + // Ordering always appends `id ASC` as a tiebreaker so pagination stays + // stable: `role` is not unique, so without the secondary sort the same + // rows could drift across pages. `joinedAt` maps to the member's createdAt. + const orderBy: Prisma.MemberOrderByWithRelationInput[] = + sort === "role" + ? [{ roles: { _count: "desc" } }, { id: "asc" }] + : [{ createdAt: "asc" }, { id: "asc" }]; + + // Run findMany and count in parallel. The COUNT(*) adds a second query and + // some latency for large communities, but it is required to return an + // honest `total` — we deliberately avoid approximations or caching here. + const [members, total] = await Promise.all([ + prismaClient.member.findMany({ + where, + include: { + wallet: true, + membership: true, + roles: { + where: { active: true }, + }, + profile: true, + }, + orderBy, + skip, + take: safePageSize, + }), + prismaClient.member.count({ where }), + ]); + + const data = members.map((m: any) => { + const activeRoles = m.roles + .filter((r: any) => r.active) + .map((r: any) => r.role); + return { + wallet: m.wallet.address, + displayName: m.profile?.displayName ?? null, + state: getNormalizedMembershipState( + m.membership?.state ?? "invited", + m.membership?.expiresAt, + ), + roles: activeRoles, + joinedAt: m.createdAt.toISOString(), + }; + }); + + return { + data, + total, + page, + pageSize: safePageSize, + nextCursor: null, // reserved for a future migration to cursor pagination + }; + } + return { async getMembershipsByWallet(wallet: string, communityId?: string) { const cacheKey = communityId @@ -864,59 +998,17 @@ export function getMemberService( checkAccess, - async listMembersForAdmin( + listMembersForAdmin( communityId: string, - role?: Role, - pagination: { - limit?: number; - cursor?: string; - status?: MembershipState; + options: { + role?: Role; + status?: string; + page?: number; + pageSize?: number; + sort?: "joinedAt" | "role"; } = {}, ) { - const limit = Math.min(Math.max(pagination.limit ?? 50, 1), 200); - const members = await prismaClient.member.findMany({ - where: { - communityId, - ...(role ? { roles: { some: { role, active: true } } } : {}), - }, - include: { wallet: true, membership: true, roles: true, profile: true }, - orderBy: { id: "asc" }, - take: limit + 1, - ...(pagination.cursor - ? { cursor: { id: pagination.cursor }, skip: 1 } - : {}), - }); - const page = members.slice(0, limit); - const list = page - .map((m: any) => { - const activeRoles = m.roles - .filter((r: any) => r.active) - .map((r: any) => r.role); - return { - id: m.id as string, - wallet: m.wallet.address, - displayName: m.profile?.displayName ?? null, - state: getNormalizedMembershipState( - m.membership?.state ?? "invited", - m.membership?.expiresAt, - ), - roles: activeRoles, - }; - }) - .filter((item: any) => (role ? item.roles.includes(role) : true)) - .filter((item: any) => - pagination.status ? item.state === pagination.status : true, - ); - const hasMore = members.length > limit; - return { - communityId, - members: list.map(({ id: _id, ...rest }) => rest), - pagination: { - limit, - hasMore, - nextCursor: hasMore ? page[page.length - 1]?.id ?? null : null, - }, - }; + return listMembersForAdmin(communityId, options); }, async assignMemberRole( diff --git a/apps/access-api/test/openApiContract.test.ts b/apps/access-api/test/openApiContract.test.ts index 59c88b4..6d1f1b4 100644 --- a/apps/access-api/test/openApiContract.test.ts +++ b/apps/access-api/test/openApiContract.test.ts @@ -233,16 +233,14 @@ const fixtures = { membershipState: 'active', }, communityMembers200: { - communityId: COMMUNITY, - members: [ - { wallet: '0x1111111111111111111111111111111111111111', displayName: 'Alice', state: 'active', roles: ['admin'] }, - { wallet: '0x2222222222222222222222222222222222222222', displayName: 'Bob', state: 'active', roles: ['member'] }, + data: [ + { wallet: '0x1111111111111111111111111111111111111111', displayName: 'Alice', state: 'active', roles: ['admin'], joinedAt: '2026-01-01T00:00:00.000Z' }, + { wallet: '0x2222222222222222222222222222222222222222', displayName: 'Bob', state: 'active', roles: ['member'], joinedAt: '2026-01-02T00:00:00.000Z' }, ], - pagination: { - limit: 20, - hasMore: false, - nextCursor: null, - } + total: 2, + page: 1, + pageSize: 25, + nextCursor: null, }, deadLetterEvents200: { events: [ diff --git a/apps/access-api/test/rateLimit.test.ts b/apps/access-api/test/rateLimit.test.ts index b2395d0..484a506 100644 --- a/apps/access-api/test/rateLimit.test.ts +++ b/apps/access-api/test/rateLimit.test.ts @@ -14,15 +14,13 @@ jest.mock('../src/services/memberService', () => { return { getMemberService: jest.fn().mockReturnValue({ getMembershipsByWallet: jest.fn().mockResolvedValue([]), + isCommunityAdmin: jest.fn().mockResolvedValue(true), listMembersForAdmin: jest.fn().mockResolvedValue({ - communityId: 'community-1', - members: [], - pagination: { - page: 1, - limit: 20, - total: 0, - totalPages: 0, - }, + data: [], + total: 0, + page: 1, + pageSize: 25, + nextCursor: null, }), }), }; diff --git a/apps/access-api/test/routes.integration.test.ts b/apps/access-api/test/routes.integration.test.ts index ac7f172..9c4decc 100644 --- a/apps/access-api/test/routes.integration.test.ts +++ b/apps/access-api/test/routes.integration.test.ts @@ -134,29 +134,41 @@ async function buildTestApp( app.get("/v1/communities/:communityId/members", async (request, reply) => { const { communityId } = request.params as { communityId: string }; - // Mirror production Zod bounds (#236): default 50, max 200 → 400 when exceeded. + // The integration test app doesn't enforce auth; service unit tests do. + // This mirrors the real route's offset pagination + sort validation (#259). const query = request.query as { role?: string; status?: string; - limit?: string; - cursor?: string; + page?: string; + pageSize?: string; + sort?: string; }; - if (query.limit !== undefined) { - const n = Number(query.limit); - if (!Number.isInteger(n) || n < 1 || n > 200) { - return reply.status(400).send({ - error: { - code: "VALIDATION_ERROR", - message: "Invalid query parameters: limit must be an integer from 1 to 200", - }, - }); - } + const page = query.page !== undefined ? Number(query.page) : 1; + const pageSize = query.pageSize !== undefined ? Number(query.pageSize) : 25; + const sort = (query.sort ?? "joinedAt") as "joinedAt" | "role"; + + if (!Number.isInteger(page) || page < 1) { + return reply + .status(400) + .send(apiError({ statusCode: 400, code: "VALIDATION_ERROR", message: "page must be >= 1" })); } - const limit = query.limit ? Number(query.limit) : 50; - return mockService.listMembersForAdmin(communityId, query.role, { - limit, - cursor: query.cursor, + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) { + return reply + .status(400) + .send(apiError({ statusCode: 400, code: "VALIDATION_ERROR", message: "pageSize must be between 1 and 100" })); + } + if (sort !== "joinedAt" && sort !== "role") { + return reply + .status(400) + .send(apiError({ statusCode: 400, code: "VALIDATION_ERROR", message: "invalid sort" })); + } + + return mockService.listMembersForAdmin(communityId, { + role: query.role, status: query.status, + page, + pageSize, + sort, }); }); @@ -437,15 +449,8 @@ describe("POST /v1/access/check", () => { }); describe("GET /v1/communities/:communityId/members", () => { - test("uses default page size of 50 when limit is omitted", async () => { - const mockData = { - communityId: "community-1", - members: [ - { wallet: "0x1", displayName: null, state: "active", roles: ["member"] }, - { wallet: "0x2", displayName: null, state: "active", roles: ["admin"] }, - ], - pagination: { limit: 50, hasMore: false, nextCursor: null }, - }; + test("returns the paginated envelope for a community (defaults)", async () => { + const mockData = API_CONTRACT.communityMembers.successResponse; const mock = createMockMemberService({ listMembersForAdmin: jest.fn().mockResolvedValue(mockData), }); @@ -457,99 +462,121 @@ describe("GET /v1/communities/:communityId/members", () => { }); expect(response.statusCode).toBe(200); - expect(response.json().pagination).toEqual({ - limit: 50, - hasMore: false, + const body = response.json(); + expect(body.data).toHaveLength(2); + expect(body).toMatchObject({ + total: 2, + page: 1, + pageSize: 25, nextCursor: null, }); - expect(mock.listMembersForAdmin).toHaveBeenCalledWith( - "community-1", - undefined, - { limit: 50, cursor: undefined, status: undefined }, - ); + expect(mock.listMembersForAdmin).toHaveBeenCalledWith("community-1", { + role: undefined, + status: undefined, + page: 1, + pageSize: 25, + sort: "joinedAt", + }); await app.close(); }); - test("accepts custom page size and role/status filters", async () => { + test("forwards role, page, pageSize and sort query params", async () => { const mock = createMockMemberService({ listMembersForAdmin: jest.fn().mockResolvedValue({ - communityId: "community-1", - members: [], - pagination: { limit: 10, hasMore: false, nextCursor: null }, + data: [], + total: 0, + page: 2, + pageSize: 10, + nextCursor: null, }), }); const app = await buildTestApp(mock); const response = await app.inject({ - method: "GET", - url: "/v1/communities/community-1/members?role=admin&status=active&limit=10", + method: API_CONTRACT.communityMembers.method, + url: "/v1/communities/community-1/members?role=admin&page=2&pageSize=10&sort=role", }); expect(response.statusCode).toBe(200); - expect(mock.listMembersForAdmin).toHaveBeenCalledWith("community-1", "admin", { - limit: 10, - cursor: undefined, - status: "active", + expect(mock.listMembersForAdmin).toHaveBeenCalledWith("community-1", { + role: "admin", + status: undefined, + page: 2, + pageSize: 10, + sort: "role", }); await app.close(); }); - test("returns 400 when limit exceeds max page size of 200", async () => { + test("rejects out-of-range pagination with 400 and does not hit the service", async () => { const mock = createMockMemberService({ listMembersForAdmin: jest.fn(), }); const app = await buildTestApp(mock); - const response = await app.inject({ + const pageZero = await app.inject({ method: "GET", - url: "/v1/communities/community-1/members?limit=201", + url: "/v1/communities/community-1/members?page=0", }); + expect(pageZero.statusCode).toBe(400); + + const pageSizeTooLarge = await app.inject({ + method: "GET", + url: "/v1/communities/community-1/members?pageSize=200", + }); + expect(pageSizeTooLarge.statusCode).toBe(400); - expect(response.statusCode).toBe(400); expect(mock.listMembersForAdmin).not.toHaveBeenCalled(); await app.close(); }); - test("continues across two pages via nextCursor", async () => { + test("walks two pages by incrementing page", async () => { const listMembersForAdmin = jest .fn() .mockResolvedValueOnce({ - communityId: "community-1", - members: [{ wallet: "0x1", displayName: null, state: "active", roles: [] }], - pagination: { limit: 1, hasMore: true, nextCursor: "member-1" }, + data: [ + { wallet: "0x1", displayName: null, state: "active", roles: [], joinedAt: "2026-01-01T00:00:00.000Z" }, + ], + total: 2, + page: 1, + pageSize: 1, + nextCursor: null, }) .mockResolvedValueOnce({ - communityId: "community-1", - members: [{ wallet: "0x2", displayName: null, state: "active", roles: [] }], - pagination: { limit: 1, hasMore: false, nextCursor: null }, + data: [ + { wallet: "0x2", displayName: null, state: "active", roles: [], joinedAt: "2026-01-02T00:00:00.000Z" }, + ], + total: 2, + page: 2, + pageSize: 1, + nextCursor: null, }); const mock = createMockMemberService({ listMembersForAdmin }); const app = await buildTestApp(mock); const page1 = await app.inject({ method: "GET", - url: "/v1/communities/community-1/members?limit=1", + url: "/v1/communities/community-1/members?pageSize=1", }); expect(page1.statusCode).toBe(200); - expect(page1.json().pagination.nextCursor).toBe("member-1"); + expect(page1.json().total).toBe(2); + expect(page1.json().data[0].wallet).toBe("0x1"); const page2 = await app.inject({ method: "GET", - url: `/v1/communities/community-1/members?limit=1&cursor=${page1.json().pagination.nextCursor}`, + url: "/v1/communities/community-1/members?pageSize=1&page=2", }); expect(page2.statusCode).toBe(200); - expect(page2.json().pagination).toEqual({ - limit: 1, - hasMore: false, - nextCursor: null, - }); - expect(listMembersForAdmin).toHaveBeenNthCalledWith(2, "community-1", undefined, { - limit: 1, - cursor: "member-1", + expect(page2.json().data[0].wallet).toBe("0x2"); + expect(listMembersForAdmin).toHaveBeenNthCalledWith(2, "community-1", { + role: undefined, status: undefined, + page: 2, + pageSize: 1, + sort: "joinedAt", }); await app.close(); diff --git a/docs/openapi.json b/docs/openapi.json index 5031cee..a104df8 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -7553,23 +7553,38 @@ "schema": { "type": "integer", "minimum": 1, - "maximum": 200, - "default": 50 + "default": 1 }, "in": "query", - "name": "limit", + "name": "page", + "required": false, + "description": "1-based page number. Values below 1 return 400." + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 25 + }, + "in": "query", + "name": "pageSize", "required": false, - "description": "Page size (default 50, maximum 200). Requests above 200 return 400." + "description": "Page size (default 25, maximum 100). Requests above 100 return 400." }, { "schema": { "type": "string", - "minLength": 1 + "enum": [ + "joinedAt", + "role" + ], + "default": "joinedAt" }, "in": "query", - "name": "cursor", + "name": "sort", "required": false, - "description": "Opaque cursor from a previous response's pagination.nextCursor" + "description": "Sort field. A stable id ASC tiebreaker is always applied so pages never overlap." }, { "schema": { @@ -7584,22 +7599,21 @@ ], "responses": { "200": { - "description": "Cursor-paginated member list", + "description": "Offset-paginated member list", "content": { "application/json": { "schema": { - "description": "Cursor-paginated member list", + "description": "Offset-paginated member list", "type": "object", "required": [ - "communityId", - "members", - "pagination" + "data", + "total", + "page", + "pageSize", + "nextCursor" ], "properties": { - "communityId": { - "type": "string" - }, - "members": { + "data": { "type": "array", "items": { "type": "object", @@ -7632,35 +7646,32 @@ "contributor" ] } + }, + "joinedAt": { + "type": "string", + "format": "date-time" } } } }, - "pagination": { - "type": "object", - "required": [ - "limit", - "hasMore", - "nextCursor" - ], - "properties": { - "limit": { - "type": "integer" - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string", - "nullable": true, - "description": "Pass as ?cursor= on the next request when hasMore" - } - } + "total": { + "type": "integer", + "description": "Total members matching the filters, across all pages" + }, + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Always null in offset mode; reserved for a future move to cursor pagination" } }, "example": { - "communityId": "community-mainnet-42", - "members": [ + "data": [ { "wallet": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", "displayName": "alice.eth", @@ -7668,7 +7679,8 @@ "roles": [ "admin", "member" - ] + ], + "joinedAt": "2026-01-01T00:00:00.000Z" }, { "wallet": "0xabcd1234567890abcd1234567890abcd12345678", @@ -7676,25 +7688,25 @@ "state": "active", "roles": [ "member" - ] + ], + "joinedAt": "2026-01-02T00:00:00.000Z" } ], - "pagination": { - "limit": 50, - "hasMore": false, - "nextCursor": null - } + "total": 2, + "page": 1, + "pageSize": 25, + "nextCursor": null } } } } }, "400": { - "description": "Invalid query parameters (e.g. limit above 200)", + "description": "Invalid query parameters (e.g. pageSize above 100)", "content": { "application/json": { "schema": { - "description": "Invalid query parameters (e.g. limit above 200)", + "description": "Invalid query parameters (e.g. pageSize above 100)", "type": "object", "required": [ "error", diff --git a/packages/sdk-lite/README.md b/packages/sdk-lite/README.md index 17493aa..cd5a6d2 100644 --- a/packages/sdk-lite/README.md +++ b/packages/sdk-lite/README.md @@ -85,9 +85,13 @@ try { `POST /v1/access/check` -> `{ allowed: boolean, code?: string, membershipState?: string }`. -### `client.listCommunityMembers(communityId, { role? })` +### `client.listCommunityMembers(communityId, { role?, page?, pageSize?, sort? })` -`GET /v1/communities/:communityId/members` -> `{ members }`. +`GET /v1/communities/:communityId/members` -> `{ data, total, page, pageSize, nextCursor }`. + +- `page` (default `1`, ≥ 1) and `pageSize` (default `25`, 1–100) drive offset pagination. +- `sort` is `joinedAt` (default) or `role`; `id ASC` is always applied as a stable tiebreaker. +- `nextCursor` is `null` in offset mode, reserved for a future cursor-based migration. Throws `GuildPassApiError` on any failure path (network, non-2xx, empty body, non-JSON body, JSON parse error). diff --git a/packages/sdk-lite/src/index.ts b/packages/sdk-lite/src/index.ts index d72ffcb..ecdea01 100644 --- a/packages/sdk-lite/src/index.ts +++ b/packages/sdk-lite/src/index.ts @@ -42,12 +42,17 @@ export interface AccessCheckInput { } export interface CommunityMembersResult { - members: Array<{ + data: Array<{ wallet: string; - displayName?: string; + displayName?: string | null; state: string; roles: string[]; + joinedAt: string; }>; + total: number; + page: number; + pageSize: number; + nextCursor: string | null; } export interface CommunityRolesResult { @@ -169,11 +174,21 @@ export class GuildPassClient { */ async listCommunityMembers( communityId: string, - options: { role?: string } = {}, + options: { + role?: string; + page?: number; + pageSize?: number; + sort?: 'joinedAt' | 'role'; + } = {}, ): Promise { - const query = options.role - ? `?role=${encodeURIComponent(options.role)}` - : ''; + const params = new URLSearchParams(); + if (options.role) params.set('role', options.role); + if (options.page !== undefined) params.set('page', String(options.page)); + if (options.pageSize !== undefined) params.set('pageSize', String(options.pageSize)); + if (options.sort) params.set('sort', options.sort); + + const query = params.toString() ? `?${params.toString()}` : ''; + return this._request( `/v1/communities/${encodePathSegment(communityId)}/members${query}`, { method: 'GET' }, diff --git a/packages/shared-types/contracts/v1/schemas.json b/packages/shared-types/contracts/v1/schemas.json index 652d815..907f43c 100644 --- a/packages/shared-types/contracts/v1/schemas.json +++ b/packages/shared-types/contracts/v1/schemas.json @@ -226,10 +226,10 @@ "path": "/v1/communities/{communityId}/members", "successResponse": { "type": "object", - "required": ["communityId", "members", "pagination"], + "description": "Offset-paginated member list", + "required": ["data", "total", "page", "pageSize", "nextCursor"], "properties": { - "communityId": { "type": "string" }, - "members": { + "data": { "type": "array", "items": { "type": "object", @@ -240,19 +240,21 @@ "roles": { "type": "array", "items": { "$ref": "#/sharedComponents/Role" } - } + }, + "joinedAt": { "type": "string", "format": "date-time" } } } }, - "pagination": { - "type": "object", - "required": ["page", "limit", "total", "totalPages"], - "properties": { - "page": { "type": "integer" }, - "limit": { "type": "integer" }, - "total": { "type": "integer" }, - "totalPages": { "type": "integer" } - } + "total": { + "type": "integer", + "description": "Total members matching the filters, across all pages" + }, + "page": { "type": "integer" }, + "pageSize": { "type": "integer" }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Always null in offset mode; reserved for a future move to cursor pagination" } } } diff --git a/packages/shared-types/src/apiContract.ts b/packages/shared-types/src/apiContract.ts index f9abe17..ff1a26a 100644 --- a/packages/shared-types/src/apiContract.ts +++ b/packages/shared-types/src/apiContract.ts @@ -78,29 +78,29 @@ export const API_CONTRACT = { pathTemplate: '/v1/communities/:communityId/members', samplePath: '/v1/communities/community-1/members', samplePathWithRole: '/v1/communities/community-1/members?role=admin', - samplePathWithPagination: '/v1/communities/community-1/members?limit=1&cursor=member-1', + samplePathWithPagination: '/v1/communities/community-1/members?page=2&pageSize=1&sort=joinedAt', successStatus: 200, successResponse: { - communityId: "community-1", - members: [ + data: [ { wallet: "0x1111111111111111111111111111111111111111", displayName: "Alice", state: "active", roles: ["admin"], + joinedAt: "2026-01-01T00:00:00.000Z", }, { wallet: "0x2222222222222222222222222222222222222222", displayName: "Bob", state: "active", roles: ["member"], + joinedAt: "2026-01-02T00:00:00.000Z", }, ], - pagination: { - limit: 50, - hasMore: false, - nextCursor: null, - }, + total: 2, + page: 1, + pageSize: 25, + nextCursor: null, }, errorResponse: { 404: { diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 6ab190d..12c6b09 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -509,5 +509,22 @@ export interface DeadLetterEventDto { resolvedAt?: string | null; } +// --- Generic pagination envelope --- + +/** + * Generic paginated response envelope shared across list endpoints. + * + * `nextCursor` is reserved for a future migration to cursor-based pagination: + * in offset mode it is always `null`, but keeping the field in the contract + * means switching to cursors later is not a breaking change for consumers. + */ +export interface PaginatedResponse { + data: T[]; + total: number; + page: number; + pageSize: number; + nextCursor: string | null; +} + export * from "./apiContract"; export * from "./permissions";