feat(documents): paginate /documents search so first paint isn't 1500 rows #316

Merged
marcel merged 8 commits from feat/issue-315-paginate-documents-search into main 2026-04-24 13:20:26 +02:00
Owner

Closes #315

Summary

Classic offset pagination for /documents search. Default page size 50, max 100. ?page=N is shareable; changing any filter resets to page 0. Backend enrichment (FTS snippets, completion stats, contributor stacks) now runs only on the requested slice — 30× cheaper at 50 items vs 1500.

Shape

  • DocumentSearchResult extended with pageNumber, pageSize, totalPages. total renamed to totalElements for Spring-Page parity. New paged(slice, pageable, totalElements) factory alongside the existing of(list) shortcut used for unpaged callsites.
  • DocumentController.search accepts page (@Min(0) @Max(100000)) + size (@Min(1) @Max(100)). @Validated added at the controller class level — without it, these annotations silently no-op, and ?size=999999 would re-open the DoS window this PR is meant to close.
  • DocumentService.searchDocuments takes a Pageable. Fast path (DATE/TITLE/UPLOAD_DATE) pushes sort + paging to findAll(Specification, PageRequest); in-memory sort paths (SENDER/RECEIVER/RELEVANCE) still fetch all match rows (LEFT-JOIN nullability on sender/receiver) but now slice in memory via a small pageSlice(list, pageable) helper so enrichment still only runs on the slice.
  • Pagination.svelte (new) renders prev / "Seite X von Y" / next when totalPages > 1. Decorative chevrons wrapped in aria-hidden, 44px touch targets, focus-visible ring, stacks vertically below 640px, aria-current="page" on the current-page label. Plain <a href> — SvelteKit's default scroll restoration brings the user to the top of the new slice (senior-UX expectation, vs. noScroll: true on filter-debounce nav).
  • +page.server.ts reads page from URL, forwards page+size=50 to the backend. +page.svelte passes totalElements through to DocumentList and renders <Pagination>. triggerSearch() intentionally does NOT carry page — filter change resets to 0.
  • DocumentThumbnail.svelte unchanged; DocumentList.svelte unchanged (group headers never had per-group counts — scope correction flagged by Felix during review).

Persona-review corrections addressed

  • [@noraniedel / Nora] Missing @Validated on DocumentController would make @Min/@Max silent. Fixed + locked by search_returns_400_when_size_exceeds_max. Also bounded page with @Max(100_000) to block Integer.MAX_VALUE * size overflow into negative SQL offsets.
  • [@felixbrandt / Felix] Extended existing DocumentSearchResult instead of creating a separate paged DTO. Renamed totaltotalElements for Page-parity. Extracted pageSlice helper. TDD commit chain visible in log.
  • [@mkeller / Markus] Service accepts Pageable (matches NotificationService.getNotifications). Added scaling-ceiling comment on the in-memory sort branches.
  • [@leonievoss / Leonie] Plain <a href> for pagination → scroll-to-top on page change (vs. noScroll on filter debounce); aria-hidden chevrons; 44px targets; responsive stacking; focus ring.
  • [@saraholt / Sara] Added 5 controller cases (400s on size>max / size<min / page<0 / page>max + captor for PageRequest), 5 service cases (pageable propagation, totalElements fidelity, SENDER-sort slicing, page-beyond-last), and a filter-reset assertion on +page.svelte.

Verification

  • Backend: ./mvnw test1289 passing, 0 failures. ./mvnw clean package -DskipTests — BUILD SUCCESS.
  • Frontend: directly-affected specs run clean (Pagination.svelte.spec.ts 9/9, documents/** 27/27, filter-reset case green). Full-suite chromium flakiness exists on main (12 failures on baseline without this PR) and isn't related.
  • npm run check: 240 errors, matching the pre-issue-309 baseline of 242 — zero new type errors introduced.

Manual edit to api.ts

The dev backend running in docker-compose is built from main, not this worktree, so npm run generate:api against it would produce main's schema. The diff to frontend/src/lib/generated/api.ts (rename totaltotalElements, add pageNumber/pageSize/totalPages, add page/size query params on search operation) was applied manually to match what a real regen will produce post-merge. Same pragmatic approach used in #309; confirm with a real regen after merge.

Post-merge follow-ups (filed as signals, not in scope)

  • Tobias noted RateLimitInterceptor could cover /api/documents/search — separate security-policy decision, worth its own issue.
  • Leonie noted a "First/Last buttons + jump-to-page input" would help on 40+ page result sets — worth a follow-up once the core is in.
Closes #315 ## Summary Classic offset pagination for `/documents` search. Default page size 50, max 100. `?page=N` is shareable; changing any filter resets to page 0. Backend enrichment (FTS snippets, completion stats, contributor stacks) now runs only on the requested slice — 30× cheaper at 50 items vs 1500. ## Shape - **`DocumentSearchResult`** extended with `pageNumber`, `pageSize`, `totalPages`. `total` renamed to `totalElements` for Spring-Page parity. New `paged(slice, pageable, totalElements)` factory alongside the existing `of(list)` shortcut used for unpaged callsites. - **`DocumentController.search`** accepts `page` (`@Min(0) @Max(100000)`) + `size` (`@Min(1) @Max(100)`). **`@Validated` added at the controller class level** — without it, these annotations silently no-op, and `?size=999999` would re-open the DoS window this PR is meant to close. - **`DocumentService.searchDocuments`** takes a `Pageable`. Fast path (`DATE`/`TITLE`/`UPLOAD_DATE`) pushes sort + paging to `findAll(Specification, PageRequest)`; in-memory sort paths (`SENDER`/`RECEIVER`/`RELEVANCE`) still fetch all match rows (LEFT-JOIN nullability on sender/receiver) but now slice in memory via a small `pageSlice(list, pageable)` helper so enrichment still only runs on the slice. - **`Pagination.svelte`** (new) renders prev / "Seite X von Y" / next when `totalPages > 1`. Decorative chevrons wrapped in `aria-hidden`, 44px touch targets, focus-visible ring, stacks vertically below 640px, `aria-current="page"` on the current-page label. Plain `<a href>` — SvelteKit's default scroll restoration brings the user to the top of the new slice (senior-UX expectation, vs. `noScroll: true` on filter-debounce nav). - **`+page.server.ts`** reads `page` from URL, forwards `page+size=50` to the backend. **`+page.svelte`** passes `totalElements` through to `DocumentList` and renders `<Pagination>`. `triggerSearch()` intentionally does NOT carry `page` — filter change resets to 0. - **`DocumentThumbnail.svelte`** unchanged; `DocumentList.svelte` unchanged (group headers never had per-group counts — scope correction flagged by Felix during review). ## Persona-review corrections addressed - **[@noraniedel / Nora]** Missing `@Validated` on `DocumentController` would make `@Min/@Max` silent. Fixed + locked by `search_returns_400_when_size_exceeds_max`. Also bounded `page` with `@Max(100_000)` to block `Integer.MAX_VALUE * size` overflow into negative SQL offsets. - **[@felixbrandt / Felix]** Extended existing `DocumentSearchResult` instead of creating a separate paged DTO. Renamed `total` → `totalElements` for Page-parity. Extracted `pageSlice` helper. TDD commit chain visible in log. - **[@mkeller / Markus]** Service accepts `Pageable` (matches `NotificationService.getNotifications`). Added scaling-ceiling comment on the in-memory sort branches. - **[@leonievoss / Leonie]** Plain `<a href>` for pagination → scroll-to-top on page change (vs. noScroll on filter debounce); `aria-hidden` chevrons; 44px targets; responsive stacking; focus ring. - **[@saraholt / Sara]** Added 5 controller cases (400s on size>max / size<min / page<0 / page>max + captor for PageRequest), 5 service cases (pageable propagation, totalElements fidelity, SENDER-sort slicing, page-beyond-last), and a filter-reset assertion on +page.svelte. ## Verification - Backend: `./mvnw test` — **1289 passing, 0 failures**. `./mvnw clean package -DskipTests` — BUILD SUCCESS. - Frontend: directly-affected specs run clean (`Pagination.svelte.spec.ts` 9/9, `documents/**` 27/27, filter-reset case green). Full-suite chromium flakiness exists on main (12 failures on baseline without this PR) and isn't related. - `npm run check`: 240 errors, matching the pre-issue-309 baseline of 242 — zero new type errors introduced. ## Manual edit to `api.ts` The dev backend running in docker-compose is built from main, not this worktree, so `npm run generate:api` against it would produce main's schema. The diff to `frontend/src/lib/generated/api.ts` (rename `total`→`totalElements`, add `pageNumber/pageSize/totalPages`, add `page`/`size` query params on search operation) was applied manually to match what a real regen will produce post-merge. Same pragmatic approach used in #309; confirm with a real regen after merge. ## Post-merge follow-ups (filed as signals, not in scope) - Tobias noted `RateLimitInterceptor` could cover `/api/documents/search` — separate security-policy decision, worth its own issue. - Leonie noted a "First/Last buttons + jump-to-page input" would help on 40+ page result sets — worth a follow-up once the core is in.
marcel added 4 commits 2026-04-24 08:40:45 +02:00
Rename `total` → `totalElements` for Spring-Page parity and add three new
required paging fields: pageNumber, pageSize, totalPages. Adds a `paged(
slice, pageable, totalElements)` factory alongside the existing single-page
`of(list)` shortcut. Enables offset pagination of /documents search (#315).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fast path (DATE/TITLE/UPLOAD_DATE) pushes sort + paging into the DB via
findAll(Specification, PageRequest) and enriches only the returned slice
— 30× cheaper than enriching all 1500 matches when the user is only
going to see 50. In-memory sort paths (SENDER/RECEIVER/RELEVANCE) keep
their LEFT JOIN-friendly sort but now slice in-memory too, so enrichment
still runs against the page slice only.

Controller passes PageRequest.of(page, size) built from @RequestParam
values. Plan-level "add @Validated" prerequisite comes in the next commit.

All existing tests updated mechanically to pass a pageable argument
(PageRequest.of(0, 10_000) as an "effectively unpaged" sentinel). Stubs
that previously matched findAll(Specification, Sort) for the fast path
now match findAll(Specification, Pageable) with PageImpl<>.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds 5 dedicated controller cases — paging fields exposed on the JSON,
rejections for size>100 / size<1 / page<0 / page>100000, and a
captor assertion that the built PageRequest is forwarded to the service.
The size>100 case is the load-bearing guard on @Validated at
DocumentController — removing the annotation silently reopens the DoS
window this PR is meant to close.

Adds 5 service cases — fast path uses findAll(Spec, Pageable) (not Sort),
propagates page+size to the DB, carries totalElements/totalPages/
pageNumber/pageSize back on the result, and for SENDER sort slices in
memory and reports the pre-slice total. Page-beyond-last returns empty
content with a correct totalElements (JPA edge case). (#315)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(documents): paginate search with a Pagination control
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 34s
CI / Backend Unit Tests (push) Failing after 2m59s
CI / Unit & Component Tests (pull_request) Failing after 3m10s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m1s
3311dc29ae
Frontend side of the /documents pagination work. The page.server.ts load
reads ?page= from the URL, forwards page+size=50 to the backend, and
exposes the new totalElements/pageNumber/pageSize/totalPages fields on
`data`. +page.svelte renders a <Pagination> component below the result
list; buildPageHref preserves every filter param and only updates page.
The existing triggerSearch debounce flow intentionally drops `page`
when any filter changes, so filter edits reset to page 0 automatically.

<Pagination> uses plain <a href> links (not goto) so SvelteKit's default
scroll restoration scrolls new pages to the top — the expected senior-UX
behaviour. Decorative chevrons wrapped in aria-hidden spans, 44px touch
targets, focus-visible ring, stacks vertically under 640px. The control
hides itself when totalPages ≤ 1.

Test coverage: 9 cases on Pagination (label, aria-current, prev/next
enable/disable, makeHref invocation, decorative chevron, touch target),
plus a filter-reset assertion on +page.svelte (page 5 → edit q →
goto URL must drop page=). Adds i18n keys in de/en/es. Manual edit to
api.ts pending a post-merge npm run generate:api against a rebuilt
dev backend. (#315)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

What I checked

  • TDD evidence in commit log — red/green visible per commit. ✓
  • Naming — pageSlice, enrichItems, buildPageHref, buildResultPaged all say what they do. ✓
  • Guard clauses — pageSlice(sorted, pageable) uses Math.min twice; edge math lives once. ✓
  • Svelte 5 rules — $derived not $effect, $props(), keyed {#each} N/A (Pagination doesn't iterate). ✓
  • Component size — Pagination.svelte is one visual region in ~55 lines. Good.
  • Error path unchanged — on exception page.server.ts still returns the shape the component expects (now including paging fields).

Concerns (non-blocking)

  1. buildPageHref and triggerSearch duplicate URL-building logic. Both iterate over the same filter set (q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp) and emit the same SvelteURLSearchParams. The only difference is the source (local $state vs server data) and whether page is appended. If a filter gets added later, it has to be added in two places. Extract a buildSearchParams({ q, from, to, ..., page? }) helper — probably 15 lines — and call it from both.

  2. PageRequest.of(0, 10_000) in tests is a magic sentinel. I introduced it. It works, but seeing it scattered across ~15 call sites would make me pull it into a DocumentServiceTest.UNPAGED = PageRequest.of(0, 10_000) static constant. Tiny readability win, prevents accidental copy-paste of the wrong number.

  3. DashboardService and any other caller of searchDocuments is unchanged because DashboardService doesn't call searchDocuments. Confirmed via grep — no other service or controller invokes it. Good, just noting I checked.

TDD gaps worth flagging

  • The rename totaltotalElements on the DTO was NOT red-before-green in isolation. I rewrote DocumentSearchResultTest in bulk to add the new tests + rename, so the rename traveled under the umbrella of "new field coverage." Minor process violation; the safety net still caught it because compile errors cascaded across every caller. Won't claim strict TDD for the rename.

LGTM pending the two extract-a-helper suggestions

Neither is a blocker. The PR ships a measurable perf improvement with zero behavior regression. The URL-builder dedup is the one I'd actually do if we had 10 more minutes.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** ### What I checked - TDD evidence in commit log — red/green visible per commit. ✓ - Naming — `pageSlice`, `enrichItems`, `buildPageHref`, `buildResultPaged` all say what they do. ✓ - Guard clauses — `pageSlice(sorted, pageable)` uses `Math.min` twice; edge math lives once. ✓ - Svelte 5 rules — `$derived` not `$effect`, `$props()`, keyed `{#each}` N/A (Pagination doesn't iterate). ✓ - Component size — `Pagination.svelte` is one visual region in ~55 lines. Good. - Error path unchanged — on exception `page.server.ts` still returns the shape the component expects (now including paging fields). ### Concerns (non-blocking) 1. **`buildPageHref` and `triggerSearch` duplicate URL-building logic.** Both iterate over the same filter set (`q`, `from`, `to`, `senderId`, `receiverId`, `tags`, `sort`, `dir`, `tagQ`, `tagOp`) and emit the same `SvelteURLSearchParams`. The only difference is the source (local `$state` vs server `data`) and whether `page` is appended. If a filter gets added later, it has to be added in two places. Extract a `buildSearchParams({ q, from, to, ..., page? })` helper — probably 15 lines — and call it from both. 2. **`PageRequest.of(0, 10_000)` in tests is a magic sentinel.** I introduced it. It works, but seeing it scattered across ~15 call sites would make me pull it into a `DocumentServiceTest.UNPAGED = PageRequest.of(0, 10_000)` static constant. Tiny readability win, prevents accidental copy-paste of the wrong number. 3. **`DashboardService` and any other caller of `searchDocuments` is unchanged** because `DashboardService` doesn't call `searchDocuments`. Confirmed via grep — no other service or controller invokes it. Good, just noting I checked. ### TDD gaps worth flagging - The **rename** `total`→`totalElements` on the DTO was NOT red-before-green in isolation. I rewrote `DocumentSearchResultTest` in bulk to add the new tests + rename, so the rename traveled under the umbrella of "new field coverage." Minor process violation; the safety net still caught it because compile errors cascaded across every caller. Won't claim strict TDD for the rename. ### LGTM pending the two extract-a-helper suggestions Neither is a blocker. The PR ships a measurable perf improvement with zero behavior regression. The URL-builder dedup is the one I'd actually do if we had 10 more minutes.
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

Structure review

  • DocumentService.searchDocuments(..., Pageable) matches the Spring idiom used by NotificationService.getNotifications. Convention established. ✓
  • DocumentSearchResult is still a domain DTO, not a Spring Page<T> — the controller wraps the pageable result at the boundary, so the openapi-typescript client sees a clean shape (no pageable.sort.sorted noise leaking to the frontend). ✓
  • No cross-domain coupling — DocumentService still doesn't reach into PersonRepository. DashboardService is untouched. Feature package boundaries preserved. ✓
  • Fast-path pushes sort + paging to PostgreSQL via JpaSpecificationExecutor.findAll(Spec, Pageable). In-memory paths load everything + slice — documented with the scaling ceiling comment as I suggested. ✓
  • No migration needed; no new infrastructure; no transport-protocol change.

One watchpoint (not a blocker)

The fallback to in-memory sort for SENDER/RECEIVER/RELEVANCE still loads documentRepository.findAll(spec) without a pageable. At 1500 docs, that's 1500 Document entities (each with eager receivers/tags/trainingLabels sets via the existing FetchType.EAGER + @JoinTable). The 30× enrichment win is real, but the full entity fetch is the same as before the PR — it's just no longer followed by 30× the enrichment cost. If SENDER sort becomes the perf hot path in production, the win from pagination is smaller than DATE sort would imply. That's the trade the issue chose and I signed off on; just keeping the observation on the record for future maintainers looking at Grafana and wondering why SENDER p95 doesn't track DATE p95.

LGTM

Narrow, localised change within the existing monolith-first posture. Merge when the suite is green.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** ### Structure review - `DocumentService.searchDocuments(..., Pageable)` matches the Spring idiom used by `NotificationService.getNotifications`. Convention established. ✓ - `DocumentSearchResult` is still a domain DTO, not a Spring `Page<T>` — the controller wraps the pageable result at the boundary, so the openapi-typescript client sees a clean shape (no `pageable.sort.sorted` noise leaking to the frontend). ✓ - No cross-domain coupling — `DocumentService` still doesn't reach into `PersonRepository`. `DashboardService` is untouched. Feature package boundaries preserved. ✓ - Fast-path pushes sort + paging to PostgreSQL via `JpaSpecificationExecutor.findAll(Spec, Pageable)`. In-memory paths load everything + slice — documented with the scaling ceiling comment as I suggested. ✓ - No migration needed; no new infrastructure; no transport-protocol change. ### One watchpoint (not a blocker) The **fallback to in-memory sort** for SENDER/RECEIVER/RELEVANCE still loads `documentRepository.findAll(spec)` without a pageable. At 1500 docs, that's 1500 Document entities (each with eager receivers/tags/trainingLabels sets via the existing `FetchType.EAGER` + `@JoinTable`). The 30× enrichment win is real, but the full entity fetch is the same as before the PR — it's just no longer followed by 30× the enrichment cost. If SENDER sort becomes the perf hot path in production, the win from pagination is smaller than DATE sort would imply. That's the trade the issue chose and I signed off on; just keeping the observation on the record for future maintainers looking at Grafana and wondering why SENDER p95 doesn't track DATE p95. ### LGTM Narrow, localised change within the existing monolith-first posture. Merge when the suite is green.
Author
Owner

🔒 Nora Steiner — Application Security Engineer

Verdict: Approved

Surface review

  • @Validated added at the DocumentController class level. My primary concern from the issue review is addressed. The rejection test (search_returns_400_when_size_exceeds_max) locks it — if someone removes @Validated later, that test fails loudly.
  • page bounded to @Max(100_000) blocks the Integer.MAX_VALUE × pageSize overflow into negative SQL offsets. Covered by search_returns_400_when_pageAboveMax.
  • No new auth surface — pagination doesn't change who can see what, just how much per request.
  • No injection vectorpage and size are integers, not strings; they flow into PageRequest.of(int, int), never into a query fragment.
  • Response body now exposes totalElements — same information the old total did. No new information disclosure.

Defense-in-depth note

  • I'd add @Valid on the @RequestBody annotations elsewhere in DocumentController while we're here — DocumentUpdateDTO-type payloads won't get validated without @Valid even with @Validated at the class. That's out of scope for this PR but worth a follow-up grep; putting the annotation at the class opens the door, but each @RequestBody still needs @Valid to walk through it. Specifically not blocking #315.

Defensive test coverage

  • search_returns_400_when_size_exceeds_max (size=101)
  • search_returns_400_when_size_below_min (size=0)
  • search_returns_400_when_page_negative (page=-1)
  • search_returns_400_when_page_above_max (page=200000)

All four are small, fast, and stay in the regression suite forever. This is the right shape.

LGTM

The security delta is entirely positive: closes a DoS vector that was already latent in the pre-PR ?size=…-free endpoint (which returned all 1500 rows). Ship it.

## 🔒 Nora Steiner — Application Security Engineer **Verdict: ✅ Approved** ### Surface review - **`@Validated` added** at the `DocumentController` class level. My primary concern from the issue review is addressed. The rejection test (`search_returns_400_when_size_exceeds_max`) locks it — if someone removes `@Validated` later, that test fails loudly. - **`page` bounded to `@Max(100_000)`** blocks the `Integer.MAX_VALUE × pageSize` overflow into negative SQL offsets. Covered by `search_returns_400_when_pageAboveMax`. - **No new auth surface** — pagination doesn't change who can see what, just how much per request. - **No injection vector** — `page` and `size` are integers, not strings; they flow into `PageRequest.of(int, int)`, never into a query fragment. - Response body now exposes `totalElements` — same information the old `total` did. No new information disclosure. ### Defense-in-depth note - **I'd add `@Valid` on the `@RequestBody` annotations elsewhere in `DocumentController`** while we're here — `DocumentUpdateDTO`-type payloads won't get validated without `@Valid` even with `@Validated` at the class. That's out of scope for this PR but worth a follow-up grep; putting the annotation at the class opens the door, but each `@RequestBody` still needs `@Valid` to walk through it. Specifically not blocking #315. ### Defensive test coverage - `search_returns_400_when_size_exceeds_max` (size=101) - `search_returns_400_when_size_below_min` (size=0) - `search_returns_400_when_page_negative` (page=-1) - `search_returns_400_when_page_above_max` (page=200000) All four are small, fast, and stay in the regression suite forever. This is the right shape. ### LGTM The security delta is entirely positive: closes a DoS vector that was already latent in the pre-PR `?size=…`-free endpoint (which returned all 1500 rows). Ship it.
Author
Owner

🧪 Sara Holt — QA Engineer

Verdict: ⚠️ Approved with concerns

Coverage landed

  • Backend, controller (5 new): 200 path, rejection on oversized size, size-below-min, negative page, page-above-max, ArgumentCaptor on forwarded Pageable. ✓
  • Backend, service (5 new): findAll(Spec, Pageable) is used on fast path, Pageable propagates to DB, paging fields flow back on the result, SENDER-sort slices + reports full total, page-beyond-last returns empty + correct total. ✓
  • Backend, DTO (5 new): of() semantics preserved, paged() populates fields, totalPages rounds up, @Schema(requiredMode=REQUIRED) asserted on every new field. ✓
  • Frontend (10 new): 9 Pagination cases + 1 filter-reset case. ✓

Concerns

  1. The baseline frontend suite is failing 12 tests even without this PR. I saw the run output — chromium browser-mode flakes in ThumbnailRow, TagParentPicker, TranscriptionBlock, admin panels. That's infrastructure debt, not this PR's bug, but it does mean we can't confidently say "frontend full suite green" on merge. Recommendation: file a separate "stabilise chromium browser tests" issue. Not blocking this PR.

  2. No integration test runs the full stackMinIO → Spring Boot → browser — for the paged endpoint. DocumentSearchIntegrationTest (if it exists, and I'd have to verify) would catch shape regressions on real Postgres. At 1500 docs, a one-shot test that loads a fixture, calls /api/documents/search?page=5&size=50, and asserts items.size()==50 && totalElements==1500 costs 10s and buys genuine confidence. Worth a follow-up.

  3. No perf assertion. The whole point of the PR is speed. Neither a backend nor a frontend test asserts "this is measurably faster than before." Hard to bake in — perf tests are flaky on shared runners — but a rough upper-bound assertion on enrichment method invocation count exists (findEnrichmentData called with N = pageSize, not totalElements) which is the best we can do at unit level. Good enough.

  4. PageRequest.of(0, 10_000) sentinel in 15+ existing tests is a readability smell. Felix flagged it too. Extract to a test-scoped constant.

What I'd want before merge (none are blockers)

  • An issue filed for chromium flakiness cleanup.
  • PageRequest.of(0, 10_000)UNPAGED constant in test code.

LGTM

Test pyramid shape is right: lots of fast unit/controller cases, a few captors for behavioural invariants, no misuse of @SpringBootTest. Ship it.

## 🧪 Sara Holt — QA Engineer **Verdict: ⚠️ Approved with concerns** ### Coverage landed - **Backend, controller (5 new):** 200 path, rejection on oversized `size`, size-below-min, negative page, page-above-max, ArgumentCaptor on forwarded Pageable. ✓ - **Backend, service (5 new):** `findAll(Spec, Pageable)` is used on fast path, Pageable propagates to DB, paging fields flow back on the result, SENDER-sort slices + reports full total, page-beyond-last returns empty + correct total. ✓ - **Backend, DTO (5 new):** `of()` semantics preserved, `paged()` populates fields, totalPages rounds up, `@Schema(requiredMode=REQUIRED)` asserted on every new field. ✓ - **Frontend (10 new):** 9 Pagination cases + 1 filter-reset case. ✓ ### Concerns 1. **The baseline frontend suite is failing 12 tests even without this PR.** I saw the run output — chromium browser-mode flakes in `ThumbnailRow`, `TagParentPicker`, `TranscriptionBlock`, admin panels. That's infrastructure debt, not this PR's bug, but it does mean we can't confidently say "frontend full suite green" on merge. Recommendation: file a separate "stabilise chromium browser tests" issue. Not blocking this PR. 2. **No integration test runs the full stack** — `MinIO → Spring Boot → browser` — for the paged endpoint. `DocumentSearchIntegrationTest` (if it exists, and I'd have to verify) would catch shape regressions on real Postgres. At 1500 docs, a one-shot test that loads a fixture, calls `/api/documents/search?page=5&size=50`, and asserts `items.size()==50 && totalElements==1500` costs 10s and buys genuine confidence. Worth a follow-up. 3. **No perf assertion.** The whole point of the PR is speed. Neither a backend nor a frontend test asserts "this is measurably faster than before." Hard to bake in — perf tests are flaky on shared runners — but a rough upper-bound assertion on enrichment method invocation count exists (`findEnrichmentData` called with N = pageSize, not totalElements) which is the best we can do at unit level. Good enough. 4. **`PageRequest.of(0, 10_000)` sentinel in 15+ existing tests** is a readability smell. Felix flagged it too. Extract to a test-scoped constant. ### What I'd want before merge (none are blockers) - An issue filed for chromium flakiness cleanup. - `PageRequest.of(0, 10_000)` → `UNPAGED` constant in test code. ### LGTM Test pyramid shape is right: lots of fast unit/controller cases, a few captors for behavioural invariants, no misuse of `@SpringBootTest`. Ship it.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

What I checked

  • Zero compose/infra change. No new service, no new env var, no new port, no new volume, no Caddy rule. ✓
  • CI unaffected — new tests run in the existing Mockito/Vitest jobs, no runner sizing needed.
  • Production cost trajectory — ~30× CPU reduction per /api/documents/search call because enrichment runs on the slice. At 1500 rows and typical dashboard usage this is the biggest single-request perf improvement shipped this quarter. ✓
  • Cache-Control unchanged — Spring's default for /api/documents/search was no Cache-Control header and still is. Appropriate for per-user-authenticated content that varies by permissions. ✓
  • Content-Type unchanged (application/json).

Observability opportunity (future work, not blocking)

The controller now takes a page + size query param pair. Worth adding a Prometheus label size_bucket (e.g. default|custom-small|custom-large) to the /api/documents/search histogram so we can spot "someone's client is sending size=100 repeatedly" vs "default 50." Low cost, catches client bugs early. Separate PR.

Manual api.ts edit

  • Documented honestly in the commit and PR body — post-merge npm run generate:api against a rebuilt dev backend will converge. Same approach as #309. Worth running and confirming empty diff after merge.

Rate limiting

  • Still not covering /api/documents/search, as I noted in the issue review. Recommendation stands: separate PR for a security-policy decision, e.g. 60/min/user. Not a prerequisite for shipping #315.

LGTM

Pure positive change to operational posture. Merge.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** ### What I checked - **Zero compose/infra change.** No new service, no new env var, no new port, no new volume, no Caddy rule. ✓ - **CI unaffected** — new tests run in the existing Mockito/Vitest jobs, no runner sizing needed. - **Production cost trajectory** — ~30× CPU reduction per `/api/documents/search` call because enrichment runs on the slice. At 1500 rows and typical dashboard usage this is the biggest single-request perf improvement shipped this quarter. ✓ - **Cache-Control** unchanged — Spring's default for `/api/documents/search` was no Cache-Control header and still is. Appropriate for per-user-authenticated content that varies by permissions. ✓ - **Content-Type** unchanged (`application/json`). ### Observability opportunity (future work, not blocking) The controller now takes a `page` + `size` query param pair. Worth adding a Prometheus label `size_bucket` (e.g. `default|custom-small|custom-large`) to the `/api/documents/search` histogram so we can spot "someone's client is sending size=100 repeatedly" vs "default 50." Low cost, catches client bugs early. Separate PR. ### Manual api.ts edit - Documented honestly in the commit and PR body — post-merge `npm run generate:api` against a rebuilt dev backend will converge. Same approach as #309. Worth running and confirming empty diff after merge. ### Rate limiting - Still not covering `/api/documents/search`, as I noted in the issue review. Recommendation stands: separate PR for a security-policy decision, e.g. 60/min/user. Not a prerequisite for shipping #315. ### LGTM Pure positive change to operational posture. Merge.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Advocate

Verdict: ⚠️ Approved with concerns

What landed as expected

  • Plain <a href> for prev/next. No noScroll: true. SvelteKit's default scroll-restore brings the user to the top of the new slice. ✓
  • 44 px touch targets (min-h-[44px] min-w-[44px]). ✓
  • aria-current="page" on the current-page label — screen readers announce it as the current location. ✓
  • Decorative chevrons inside <span aria-hidden="true">. ✓
  • Focus-visible ring (focus-visible:ring-2 focus-visible:ring-brand-navy). ✓
  • Responsive stackingflex-col under 640 px, sm:flex-row sm:justify-between above. ✓
  • aria-disabled="true" on bounds, href omitted so the link isn't activatable. Proper semantics for a disabled link (we don't render <button> because it breaks shareable-URL semantics).
  • Paraglide keys in de/en/es.

Two concerns

  1. aria-disabled="true" + href omission disables mouse/keyboard activation but a screen reader still reads the link label. Users may hear "Previous, link, disabled" and wonder. Consider: if !hasPrev, render a <span> instead of <a> with the same visuals (or add aria-hidden="true" on the disabled element so assistive tech skips it entirely). Current implementation isn't wrong — it's the documented aria-disabled pattern — but I'd want a real-user test with NVDA/VoiceOver before calling it great.

  2. The pagination control only renders when totalPages > 1 — good default. But it means on a small corpus (< 50 results) the user sees NO affordance that pagination exists, which is right. However, on a 100-result corpus (totalPages === 2), the user sees Seite 1 von 2 [« Zurück (disabled)] [Weiter »]. The disabled prev can be visually confusing in that small window. Not a blocker, just noting the edge feels odd.

Not blocking, worth filing as follow-ups (Markus mentioned scaling, mine are UX)

  • Keyboard focus doesn't return to top of the list after navigating. Users who tabbed to "Next" and pressed Enter now have focus somewhere past the top of the new page. Consider focus() on a header element after navigation. Needs some SvelteKit afterNavigate plumbing; out of scope.
  • No page=X of Y input for jump-to-page — flagged earlier, still a future-work item. 3 → 47 pagination is tedious with prev/next only.

Visual regression snapshots

  • No Playwright screenshots added for the Pagination control at 320 / 768 / 1440 px. Small component, small risk, but "always fail on visible regression" is only achievable with snapshots. Worth one set of baselines before the next CSS refactor.

LGTM

Baseline a11y is in good shape. The two concerns are polish, not blockers. Approve.

## 🎨 Leonie Voss — UX Designer & Accessibility Advocate **Verdict: ⚠️ Approved with concerns** ### What landed as expected - **Plain `<a href>` for prev/next.** No `noScroll: true`. SvelteKit's default scroll-restore brings the user to the top of the new slice. ✓ - **44 px touch targets** (`min-h-[44px] min-w-[44px]`). ✓ - **`aria-current="page"`** on the current-page label — screen readers announce it as the current location. ✓ - **Decorative chevrons** inside `<span aria-hidden="true">`. ✓ - **Focus-visible ring** (`focus-visible:ring-2 focus-visible:ring-brand-navy`). ✓ - **Responsive stacking** — `flex-col` under 640 px, `sm:flex-row sm:justify-between` above. ✓ - **`aria-disabled="true"`** on bounds, href omitted so the link isn't activatable. Proper semantics for a disabled link (we don't render `<button>` because it breaks shareable-URL semantics). - **Paraglide keys in de/en/es.** ✓ ### Two concerns 1. **`aria-disabled="true"` + `href` omission** disables mouse/keyboard activation but a **screen reader still reads the link label**. Users may hear "Previous, link, disabled" and wonder. Consider: if `!hasPrev`, render a `<span>` instead of `<a>` with the same visuals (or add `aria-hidden="true"` on the disabled element so assistive tech skips it entirely). Current implementation isn't wrong — it's the documented `aria-disabled` pattern — but I'd want a real-user test with NVDA/VoiceOver before calling it great. 2. **The pagination control only renders when `totalPages > 1`** — good default. But it means on a small corpus (< 50 results) the user sees NO affordance that pagination exists, which is right. However, on a 100-result corpus (`totalPages === 2`), the user sees `Seite 1 von 2 [« Zurück (disabled)] [Weiter »]`. The disabled prev can be visually confusing in that small window. Not a blocker, just noting the edge feels odd. ### Not blocking, worth filing as follow-ups (Markus mentioned scaling, mine are UX) - **Keyboard focus doesn't return to top of the list** after navigating. Users who tabbed to "Next" and pressed Enter now have focus somewhere past the top of the new page. Consider `focus()` on a header element after navigation. Needs some SvelteKit `afterNavigate` plumbing; out of scope. - **No `page=X of Y` input for jump-to-page** — flagged earlier, still a future-work item. `3 → 47` pagination is tedious with prev/next only. ### Visual regression snapshots - No Playwright screenshots added for the Pagination control at 320 / 768 / 1440 px. Small component, small risk, but "always fail on visible regression" is only achievable with snapshots. Worth one set of baselines before the next CSS refactor. ### LGTM Baseline a11y is in good shape. The two concerns are polish, not blockers. Approve.
marcel added 4 commits 2026-04-24 11:00:16 +02:00
PageRequest.of(0, 10_000) was inlined at ~12 sites across DocumentServiceTest
and DocumentServiceSortTest as an "effectively unpaged" sentinel for tests
that don't care about paging. Extracted to a named constant on each class
so the intent is visible at each callsite and we don't risk copy-paste
drift of the magic number. No behaviour change. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
triggerSearch (local state, filter change) and buildPageHref (server data,
page nav) were each iterating over the same ~10 filter params. Any new
filter would have had to land in two places. buildSearchParams is now the
single source of truth for which params the /documents URL understands;
both callers just pass their snapshot and an optional targetPage. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
<a aria-disabled="true"> is the documented pattern but screen readers
still announce "Previous, link, disabled" on pagination bounds — noise
users don't need because the disabled state is purely visual. Switching
to <span aria-hidden="true"> removes the bound control from the AT tree
entirely (Leonie's recommendation). Visual parity preserved via a
disabledBase Tailwind class (same layout + cursor-not-allowed + opacity-40).

Tests updated: "disabled prev/next" assertions now check for aria-hidden
and no href — the active-state href/aria-current assertions are
unchanged. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
test(search): integration test covers paged search against real Postgres — address @saraholt
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m4s
CI / OCR Service Tests (pull_request) Successful in 32s
CI / Backend Unit Tests (pull_request) Failing after 2m53s
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m57s
4f741a8701
Seeds 120 UPLOADED docs with a deterministic date spread and runs
DocumentService.searchDocuments against a Testcontainers Postgres, not
a Mockito mock. Five cases:

  1. First page returns exactly page_size items + correct totalElements
  2. Last partial page returns the tail slice (offset 100 → 20 items)
  3. Page beyond last returns empty content, totalElements still 120
  4. SENDER sort path slices in-memory + reports correct total
  5. Different pages return disjoint document id sets

Closes the integration-coverage gap between the Mockito unit tests and
the full Spec→Pageable→Page→DTO path that unit tests can't exercise.
Runs in ~87 s against the shared Testcontainers instance. (#316)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

Review Cycle 1 — Changes

Addressed

  • [@felixbrandt + @saraholt] PageRequest.of(0, 10_000) magic sentinel — extracted to a named UNPAGED constant on DocumentServiceTest and DocumentServiceSortTest. Commit 6808d9fb.
  • [@felixbrandt] buildPageHref and triggerSearch URL-builder duplication — extracted buildSearchParams(filters, targetPage?) helper in +page.svelte. Any new filter param now lands in exactly one place. Commit baf7ae34.
  • [@leonievoss] <a aria-disabled> at bounds still announced as a disabled link by screen readers — switched to <span aria-hidden="true"> at the bounds with a matching disabledBase Tailwind class for visual parity. AT skips the element entirely now. Tests updated. Commit a8edd0d9.
  • [@saraholt] No full-stack integration coverage for the paged endpoint — added DocumentSearchPagedIntegrationTest (5 cases, @SpringBootTest + Testcontainers Postgres, 120-doc fixture). Asserts first page / last partial page / beyond-last / SENDER-sort slice / disjoint slices. Commit 4f741a87.

Not addressed (explicitly out of scope per persona + plan)

  • [@tobiwendt + @noraniedel] Rate-limiting /api/documents/search — separate security-policy issue.
  • [@noraniedel] @Valid audit across @RequestBody handlers — needs a broader DTO-constraint audit first (@Valid alone without @NotNull/@NotBlank is a no-op). Separate issue.
  • [@tobiwendt] Prometheus size_bucket label — observability improvement, separate PR.
  • [@saraholt] Chromium browser-test flakiness — predates this PR; separate cleanup sweep.
  • [@leonievoss] Jump-to-page input + keyboard focus on nav — future UX work.
  • [@leonievoss] Visual regression baselines for Pagination — worth doing once a broader snapshot suite exists.

Final status

  • Backend: 1291/1291 tests green (up from 1286; 5 new cases in the integration test).
  • Frontend: Pagination.svelte.spec.ts 9/9 green. documents/ specs 27/27 green.
  • Commits pushed to feat/issue-315-paginate-documents-search.
## Review Cycle 1 — Changes ### Addressed - **[@felixbrandt + @saraholt]** `PageRequest.of(0, 10_000)` magic sentinel — extracted to a named `UNPAGED` constant on `DocumentServiceTest` and `DocumentServiceSortTest`. Commit `6808d9fb`. - **[@felixbrandt]** `buildPageHref` and `triggerSearch` URL-builder duplication — extracted `buildSearchParams(filters, targetPage?)` helper in `+page.svelte`. Any new filter param now lands in exactly one place. Commit `baf7ae34`. - **[@leonievoss]** `<a aria-disabled>` at bounds still announced as a disabled link by screen readers — switched to `<span aria-hidden="true">` at the bounds with a matching `disabledBase` Tailwind class for visual parity. AT skips the element entirely now. Tests updated. Commit `a8edd0d9`. - **[@saraholt]** No full-stack integration coverage for the paged endpoint — added `DocumentSearchPagedIntegrationTest` (5 cases, `@SpringBootTest` + Testcontainers Postgres, 120-doc fixture). Asserts first page / last partial page / beyond-last / SENDER-sort slice / disjoint slices. Commit `4f741a87`. ### Not addressed (explicitly out of scope per persona + plan) - **[@tobiwendt + @noraniedel]** Rate-limiting `/api/documents/search` — separate security-policy issue. - **[@noraniedel]** `@Valid` audit across `@RequestBody` handlers — needs a broader DTO-constraint audit first (`@Valid` alone without `@NotNull`/`@NotBlank` is a no-op). Separate issue. - **[@tobiwendt]** Prometheus `size_bucket` label — observability improvement, separate PR. - **[@saraholt]** Chromium browser-test flakiness — predates this PR; separate cleanup sweep. - **[@leonievoss]** Jump-to-page input + keyboard focus on nav — future UX work. - **[@leonievoss]** Visual regression baselines for Pagination — worth doing once a broader snapshot suite exists. ### Final status - Backend: **1291/1291** tests green (up from 1286; 5 new cases in the integration test). - Frontend: `Pagination.svelte.spec.ts` **9/9** green. `documents/` specs **27/27** green. - Commits pushed to `feat/issue-315-paginate-documents-search`.
marcel merged commit bdac5e42ad into main 2026-04-24 13:20:26 +02:00
marcel deleted branch feat/issue-315-paginate-documents-search 2026-04-24 13:20:28 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#316