Skip to content

feat(access-api): offset pagination and sorting for admin members list - #315

Merged
Lakes41 merged 1 commit into
Adamantine-guild:mainfrom
DiegoMoron0102:feat/issue-259-members-pagination
Jul 29, 2026
Merged

feat(access-api): offset pagination and sorting for admin members list#315
Lakes41 merged 1 commit into
Adamantine-guild:mainfrom
DiegoMoron0102:feat/issue-259-members-pagination

Conversation

@DiegoMoron0102

@DiegoMoron0102 DiegoMoron0102 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #259.

GET /v1/communities/:communityId/members now returns an offset-paginated, sortable
list using a shared PaginatedResponse envelope, replacing the cursor-based listing
added in #236:

{
  "data": [ /* members */ ],
  "total": 42,
  "page": 1,
  "pageSize": 25,
  "nextCursor": null
}

Offset pagination is what the issue's acceptance criteria require, because they ask for
a total count — cursor pagination cannot produce that cheaply.

Query parameters

Param Type Default Notes
page integer >= 1 1 400 VALIDATION_ERROR on 0, negatives, NaN
pageSize integer 1..100 25 400 VALIDATION_ERROR above the cap
sort joinedAt | role joinedAt 400 VALIDATION_ERROR on anything else
role existing filter unchanged, combines with pagination
status existing filter unchanged surface, fixed behaviour (see below)

Validation happens twice on purpose: Zod at the route layer (so bad input never reaches
the DB) and again in the service (so direct service callers get the same guarantees).

Behaviour

  • Sorting always appends an id ASC tiebreaker, so pages can never overlap or drop a
    row when several members share the same joinedAt or role.
  • total is the count of all members matching the filters, not just the current page,
    so clients can render page counts without a second request. That extra COUNT(*) is
    a deliberate cost — an approximate or cached number would mislead admins.
  • nextCursor is always null in offset mode, but stays in the contract on purpose:
    moving to cursor-based pagination later will not be a breaking change for consumers.
  • Out-of-range pages return 200 with an empty data array and the real total,
    rather than a 404.
  • Admin authorization and the route's existing preHandler chain are unchanged.

Drive-by fix: status filter was breaking pagination

The status filter was applied in JavaScript after the page had been fetched, so
?status=active&pageSize=25 could return fewer than 25 rows while more matches existed
on later pages. It is now a Prisma where predicate that mirrors
getNormalizedMembershipState(), so filtering happens before skip/take.

Contract, docs and clients

  • New generic PaginatedResponse<T> exported from @guildpass/shared-types.
  • API_CONTRACT, docs/openapi.json (regenerated via openapi:generate), the
    sdk-lite client + README, and the README endpoint table all updated.
  • packages/shared-types/contracts/v1/schemas.json — the members entry updated to the
    new shape. Flagging this for maintainers: per docs/API_VERSIONING.md a response
    reshape is a breaking change that warrants a MAJOR bump. I did not bump
    x-guildpass-api-version, following the precedent of Add pagination and filtering to the admin member listing endpoint #236 (7b885a2), which reshaped
    this same endpoint's response without a bump or snapshot update. Happy to switch to a
    contracts/v2/ snapshot and a 2.0.0 bump if you'd prefer that instead.

Testing

The full CI pipeline was run locally against this branch:

Step Result
pnpm lint:ci pass
pnpm build (incl. tsc on access-api) pass, 0 errors
pnpm test:ci 39 suites passed, 374 tests passed, 0 failures

test:ci deliberately excludes a set of suites that need a live database, and three of
the files this PR touches (memberService.test.ts, routes.integration.test.ts,
rateLimit.test.ts) fall inside that exclusion list. They were run separately and
compared against a baseline run of origin/main with these changes removed: the
failing suites are byte-for-byte the same set on both sides, and this branch adds 3 net
passing tests. Those pre-existing failures are unrelated to pagination — they need a
real database or hit known breakage in the badges/roles/outbox paths.

eslint on the touched files goes from 9 errors on main to 8 here (an unused import
became used).

New and updated coverage for this change:

  • memberService.listMembersForAdmin — defaults, skip/take math, total
    correctness, 400 on page < 1 / pageSize outside [1,100], sort=role ordering
    with tiebreaker, active-roles-only, empty tail page, and both status predicates.
  • routes.integration — envelope defaults, param forwarding, 400 for out-of-range
    values without hitting the service, and walking two pages.
  • openApiContract and apiContractCompatibility — validate against the regenerated
    spec and the updated snapshot.

Breaking change

The response shape of this endpoint changed. Consumers reading body.members or
body.pagination.hasMore must move to body.data / body.total + body.page, and
?limit=/?cursor= become ?pageSize=/?page=. sdk-lite is updated in this PR.

@DiegoMoron0102
DiegoMoron0102 force-pushed the feat/issue-259-members-pagination branch from 57e992b to d70266e Compare July 29, 2026 02:25
Closes Adamantine-guild#259

Replace the cursor-based listing of
`GET /v1/communities/:communityId/members` (added in Adamantine-guild#236) with an
offset-paginated, sortable listing that returns a shared
`PaginatedResponse` envelope:

    { data, total, page, pageSize, nextCursor }

Offset pagination is required because the issue asks for a `total` count,
which cursor pagination cannot produce cheaply.

- Add `page` (>= 1, default 1) and `pageSize` (1..100, default 25) query
  params, validated with Zod at the route layer and again in the service, so
  out-of-range values return 400 VALIDATION_ERROR instead of being clamped.
- Add `sort` (`joinedAt` | `role`, default `joinedAt`) with a stable `id ASC`
  tiebreaker, so pages never overlap or drop rows when members share a role
  or a join date.
- `total` comes from a parallel `count()` so clients can render page counts
  without a second request. The extra COUNT(*) is deliberate: an approximate
  or cached number would mislead admins.
- `nextCursor` is always null in offset mode but stays in the contract, so a
  later move to cursor pagination is not a breaking change for consumers.
- Move the pre-existing `status` filter from post-fetch JS filtering into the
  Prisma `where` clause. Filtering after skip/take silently returned short
  pages; the SQL predicate mirrors getNormalizedMembershipState().
- Export a generic `PaginatedResponse<T>` from `@guildpass/shared-types` and
  update `API_CONTRACT`, `docs/openapi.json` (regenerated), the v1 contract
  snapshot, the sdk-lite client + README, and the README endpoint table.
- Update service, integration, contract and rate-limit tests accordingly.
@DiegoMoron0102
DiegoMoron0102 force-pushed the feat/issue-259-members-pagination branch from d70266e to 0ca3725 Compare July 29, 2026 02:36
@Lakes41
Lakes41 merged commit d718557 into Adamantine-guild:main Jul 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add pagination and sorting to GET /v1/communities/:communityId/members

2 participants