fix(users): use cursor-paginated server-side search in user and task pickers - #551
Conversation
There was a problem hiding this comment.
ℹ️ No critical issues — one minor suggestion inline, plus a test-coverage nitpick.
Reviewed changes
- New cursor users endpoint —
GET /admin/users/cursor(requiresusers.read) returns{ items, page_size, next_cursor }backed by a newUserRepository.ListAfterkeyset query; invalid cursors map to400 USER_INVALID_CURSOR.GET /admin/usersis left intact for the admin page'stotal/must_change_password_count. - Add Member dialog moved to server-side search — the user infinite query is now keyed on a debounced search string and paginates the cursor endpoint, with an effect that keeps loading until a few non-member rows are visible.
- Task-link modal rewritten — replaces the 5,000-task preload/client filter with a cursor-paginated
useInfiniteQueryoverlistAllTasksusing server-sidesearch,cursor, and 20-per-page.
The keyset ordering (LOWER(COALESCE(NULLIF(full_name,''),username)), LOWER(username), id) matches List, the cursor pivot is looked up without the deleted_at filter so soft-deletes don't invalidate it, and limit+1 correctly drives hasMore. Argument placeholder math checks out in both branches.
ℹ️ Nitpicks
- No repository-level test covers
ListAfter. The keyset SQL — the most failure-prone piece, and the one the PR notes was only checked by hand against dev Postgres — has no automated coverage (user_repository_test.gotestsListbut notListAfter). A paging test there would lock in the "same order asList, no gaps/dupes across pages" contract.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…oft-deleted users
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
Reviewed the delta since the prior pullfrog review (c3956dab → 2331c34).
- Cancelled pending debounces on close — both the Add Member dialog and the task-link modal call their debounced setter with
""on close, superseding a queued timer so it can no longer restore a stale search after reset. This resolves the prior inline concern. - Hardened the users cursor —
ListAfternow returnsErrInvalidCursor(400 USER_INVALID_CURSOR) for a decodable but unknown id, while still treating soft-deleted rows as existing; previously such a cursor silently yielded an empty page. - Added error and paging guards — the task-link modal distinguishes load error from empty, and both pickers gate their auto-load-to-5 effect on
isFetchNextPageErrorandisPlaceholderData, withkeepPreviousDatasmoothing search transitions. - Restored prefixed-id search —
toServerSearchrewrites a display id likePRJ-12to#12before the server call, matching the backend's title /#<number>search.
Note: that last change means the PR description's "Behaviour change" paragraph (which states PRJ-12 no longer matches) is now stale.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Summary
Fixes #547. The Add Member dialog only filtered the first 20 loaded users in the browser, so anyone beyond the first page could never be found. This adds a cursor-paginated users endpoint and moves the search to the server. The task-link modal had the same flaw in milder form (it preloaded up to 5,000 tasks and filtered them client-side) and gets the same treatment.
Changes
API
GET /admin/users/cursor(requiresusers.read). Query params:page_size(default 20, max 100),cursor,search,role. Returns{ items, page_size, next_cursor };next_cursorisnullon the last page.400 USER_INVALID_CURSOR.GET /admin/usersis unchanged, because the admin users page relies on itstotalandmust_change_password_count.Web
next_cursor, and it keeps loading pages until a few non-member rows are visible.Behaviour change
The task-link modal's search now matches on the server against title and
#<number>(e.g.#12). Typing the full prefixed id such asPRJ-12no longer matches, which the old client-side filter did.Testing
go build,go vetand the unit and integration suites pass.ListAfteragainst a dev Postgres: paging 29 users in pages of 7, with and without a search filter, returns the same order as the offset-basedList.tsc -b, biome and the full vitest suite pass (970 tests), including newadmin-apicursor tests.