feat(search): extended search, sort options, live tag filter, result count #183

Merged
marcel merged 26 commits from feat/issue-180-extended-search-sort into main 2026-04-06 19:18:12 +02:00
Owner

Closes #180

Summary

  • Extended full-text search: hasText now matches partial sender/receiver last names and tag names via EXISTS subqueries (no duplicate rows)
  • Sort options: DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE — DocumentSort enum auto-validated by Spring MVC (400 for unknown values). SENDER and RECEIVER sort in-memory to avoid NULL-excluding INNER JOINs
  • Live tag filter: new tagQ parameter for eager tag substring search; TagInput gets onTextInput callback, SearchFilterBar wires it to onSearch
  • Search result envelope: DocumentSearchResult { documents, total } replaces bare list; frontend unwraps and shows count (N Dokumente) above the list
  • Empty state with term: shows "Keine Dokumente für "{term}" gefunden" when search yields no results
  • SortDropdown component: native <select> + direction toggle button, inline in SearchFilterBar row 1
  • Debounce utility: reusable debounce<T> in src/lib/utils/debounce.ts, 5 unit tests

Bug fixed

SENDER sort was using Sort.by("sender.lastName") which caused Hibernate to emit an INNER JOIN, silently dropping all documents with sender = null. Fixed by sorting in memory (same approach as RECEIVER).

Test plan

  • Backend: ./mvnw test — 654/654 pass
  • Frontend: npm run test — 588/591 pass (3 pre-existing failures unrelated to this PR)
  • npm run check — no new type errors
  • Manual: search ?q=W-&sort=SENDER&dir=desc returns results including documents without a sender
  • Manual: typing in the tag input triggers a live filter (debounced)
  • Manual: result count appears above document list in search mode

🤖 Generated with Claude Code

Closes #180 ## Summary - **Extended full-text search**: `hasText` now matches partial sender/receiver last names and tag names via EXISTS subqueries (no duplicate rows) - **Sort options**: DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE — `DocumentSort` enum auto-validated by Spring MVC (400 for unknown values). SENDER and RECEIVER sort in-memory to avoid NULL-excluding INNER JOINs - **Live tag filter**: new `tagQ` parameter for eager tag substring search; `TagInput` gets `onTextInput` callback, `SearchFilterBar` wires it to `onSearch` - **Search result envelope**: `DocumentSearchResult { documents, total }` replaces bare list; frontend unwraps and shows count (`N Dokumente`) above the list - **Empty state with term**: shows `"Keine Dokumente für "{term}" gefunden"` when search yields no results - **SortDropdown component**: native `<select>` + direction toggle button, inline in `SearchFilterBar` row 1 - **Debounce utility**: reusable `debounce<T>` in `src/lib/utils/debounce.ts`, 5 unit tests ## Bug fixed SENDER sort was using `Sort.by("sender.lastName")` which caused Hibernate to emit an INNER JOIN, silently dropping all documents with `sender = null`. Fixed by sorting in memory (same approach as RECEIVER). ## Test plan - [ ] Backend: `./mvnw test` — 654/654 pass - [ ] Frontend: `npm run test` — 588/591 pass (3 pre-existing failures unrelated to this PR) - [ ] `npm run check` — no new type errors - [ ] Manual: search `?q=W-&sort=SENDER&dir=desc` returns results including documents without a sender - [ ] Manual: typing in the tag input triggers a live filter (debounced) - [ ] Manual: result count appears above document list in search mode 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 13 commits 2026-04-06 14:20:53 +02:00
- hasText now JOINs sender (LEFT JOIN) and uses EXISTS subqueries for
  receivers and tags to avoid duplicate rows
- hasTagPartial added for live debounced tag text filter (ILIKE partial match)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DocumentSort enum validated by Spring MVC (400 for unknown values)
- SENDER sort uses Spring Data Sort on sender.lastName/firstName
- RECEIVER sort uses in-memory sort by first receiver alphabetically
- UPLOAD_DATE sort uses createdAt; default sort is DATE DESC
- tagQ param wired to hasTagPartial specification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(search): use in-memory sort for SENDER to include documents with null sender
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
bc397048b7
INNER JOIN from Sort.by("sender.lastName") was excluding docs without a sender.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-06 15:47:35 +02:00
fix(search): tagQ alone now triggers search mode; selecting chip clears tagQ
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
023b6ddb49
- isDashboard was ignoring tagQ so typing in tag filter showed dashboard
- addTag now calls onTextInput('') to clear tagQ when a chip is selected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-06 15:53:13 +02:00
feat(search): show spinner in search input while navigation is in-flight
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
0e13fd194b
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-06 15:56:32 +02:00
fix(ui): align SortDropdown styling with SearchFilterBar button style
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
fc3496abb6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
marcel added 1 commit 2026-04-06 15:58:09 +02:00
fix(ui): fix SortDropdown height alignment — appearance-none + items-stretch
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
79250fb705
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

Good TDD discipline throughout — the commit history shows red→green→refactor and the new spec files cover the added behaviours. A few things need addressing.


Blockers

Dead code in resolveSortDocumentService.java

The switch inside resolveSort() has SENDER and RECEIVER cases that can never be reached — both are handled before resolveSort is called:

// SENDER and RECEIVER are intercepted here:
if (sort == DocumentSort.SENDER) { ... return sortBySender(...); }
if (sort == DocumentSort.RECEIVER) { ... return sortByFirstReceiver(...); }
// So this switch only runs for DATE, TITLE, UPLOAD_DATE:
Sort springSort = resolveSort(sort, dir);

The case SENDER, case RECEIVER, and default branches in resolveSort are unreachable. Remove them. The style guide says: no dead code.

private Sort resolveSort(DocumentSort sort, String dir) {
    Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC;
    return switch (sort) {
        case DATE, null -> Sort.by(direction, "documentDate");
        case TITLE      -> Sort.by(direction, "title");
        case UPLOAD_DATE -> Sort.by(direction, "createdAt");
        // SENDER and RECEIVER never reach here
    };
}

DocumentSort lives in the model package — wrong layer

DocumentSort is not a JPA entity. It is a query parameter. It belongs in dto/ (or a dedicated query/ package). Putting a query enum in model leaks controller/service concepts into the domain model layer.

dir is an untyped String — validate at the controller boundary

@RequestParam(required = false, defaultValue = "DESC") String dir

Any string other than "ASC" / "DESC" silently falls through to DESC in resolveSort (the equalsIgnoreCase check). For SENDER/RECEIVER in-memory sort, the same happens. This is defensive but opaque — a typo like dir=des returns DESC with no feedback. Either use an enum (SortDirection) or add explicit validation.


Suggestions

sortBySender nullSafeComparator handles isEmpty() but not null properties

doc.getSender() being null is handled, but s.getLastName() or s.getFirstName() could still be null on a person with missing data:

return s != null ? s.getLastName() + " " + s.getFirstName() : "";
// if getLastName() returns null: "null firstName" → sorts as "null" string

Use Objects.toString(s.getLastName(), "") + " " + Objects.toString(s.getFirstName(), "") to be safe.

{#each} keys are correct — good. The (doc.id) and (tag.id) keys in DocumentList.svelte are exactly right.

$effect with void sort; void dir; is clever but comment-worthy. The ESLint workaround is non-obvious. A one-line comment would help the next developer.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** Good TDD discipline throughout — the commit history shows red→green→refactor and the new spec files cover the added behaviours. A few things need addressing. --- ### Blockers **Dead code in `resolveSort` — `DocumentService.java`** The `switch` inside `resolveSort()` has `SENDER` and `RECEIVER` cases that can never be reached — both are handled before `resolveSort` is called: ```java // SENDER and RECEIVER are intercepted here: if (sort == DocumentSort.SENDER) { ... return sortBySender(...); } if (sort == DocumentSort.RECEIVER) { ... return sortByFirstReceiver(...); } // So this switch only runs for DATE, TITLE, UPLOAD_DATE: Sort springSort = resolveSort(sort, dir); ``` The `case SENDER`, `case RECEIVER`, and `default` branches in `resolveSort` are unreachable. Remove them. The style guide says: **no dead code**. ```java private Sort resolveSort(DocumentSort sort, String dir) { Sort.Direction direction = "ASC".equalsIgnoreCase(dir) ? Sort.Direction.ASC : Sort.Direction.DESC; return switch (sort) { case DATE, null -> Sort.by(direction, "documentDate"); case TITLE -> Sort.by(direction, "title"); case UPLOAD_DATE -> Sort.by(direction, "createdAt"); // SENDER and RECEIVER never reach here }; } ``` **`DocumentSort` lives in the `model` package — wrong layer** `DocumentSort` is not a JPA entity. It is a query parameter. It belongs in `dto/` (or a dedicated `query/` package). Putting a query enum in `model` leaks controller/service concepts into the domain model layer. **`dir` is an untyped `String` — validate at the controller boundary** ```java @RequestParam(required = false, defaultValue = "DESC") String dir ``` Any string other than `"ASC"` / `"DESC"` silently falls through to `DESC` in `resolveSort` (the `equalsIgnoreCase` check). For SENDER/RECEIVER in-memory sort, the same happens. This is defensive but opaque — a typo like `dir=des` returns DESC with no feedback. Either use an enum (`SortDirection`) or add explicit validation. --- ### Suggestions **`sortBySender` nullSafeComparator handles `isEmpty()` but not null properties** `doc.getSender()` being null is handled, but `s.getLastName()` or `s.getFirstName()` could still be null on a person with missing data: ```java return s != null ? s.getLastName() + " " + s.getFirstName() : ""; // if getLastName() returns null: "null firstName" → sorts as "null" string ``` Use `Objects.toString(s.getLastName(), "") + " " + Objects.toString(s.getFirstName(), "")` to be safe. **`{#each}` keys are correct — good.** The `(doc.id)` and `(tag.id)` keys in `DocumentList.svelte` are exactly right. **`$effect` with `void sort; void dir;` is clever but comment-worthy.** The ESLint workaround is non-obvious. A one-line comment would help the next developer.
Author
Owner

🏗️ Markus Keller — Application Architect

Verdict: ⚠️ Approved with concerns

The feature is scoped correctly and the layering holds. Two architectural issues need to be on the radar before this scales.


Blockers

In-memory sort for SENDER/RECEIVER does not scale

if (sort == DocumentSort.SENDER) {
    List<Document> results = documentRepository.findAll(spec);  // loads ALL matching docs into memory
    return sortBySender(results, dir);
}

For a small family archive this is fine. But this path loads every matching document entity (with lazy-loaded Person relationships) into the JVM heap, then sorts. The right fix is a native-query or @Query with ORDER BY CASE WHEN sender_id IS NULL THEN 1 ELSE 0 END, p.last_name ASC — PostgreSQL handles NULLS LAST natively. This is acceptable as a v1 compromise, but it should be tracked as tech debt. A comment in the code would make the tradeoff explicit:

// TODO: replace in-memory sort with DB-level ORDER BY once Spring Data supports NULLS LAST in JPA Specs

DocumentSearchResult.total always equals documents.size() — pagination is not actually supported

public static DocumentSearchResult of(List<Document> documents) {
    return new DocumentSearchResult(documents, documents.size());
}

The API surface suggests pagination is coming (total field implies a paginated response contract), but the backend loads everything and counts after the fact. The frontend currently shows "X Dokumente" using this total. This is fine for now, but:

  1. The static factory name of() hides this limitation. Rename to ofAll() or add a comment.
  2. When pagination is added, the total will need to come from the DB (COUNT(*) in the spec query), not from list.size().

The architecture is in the right place — DocumentSearchResult as a wrapper is the correct decision. Just document the current limitation explicitly.


Suggestions

DocumentSort belongs in dto/ not model/

The model/ package should contain JPA entities and domain objects only. DocumentSort is a query parameter enum — it belongs in dto/ alongside DocumentUpdateDTO and DocumentSearchResult. This is a layer boundary violation, not a blocker for functionality, but it degrades the package structure over time.

+page.server.ts fetches ALL persons to resolve sender/receiver display names

api.GET('/api/persons')  // fetches the entire persons table on every page load

This is used only to resolve senderObj.firstName + " " + senderObj.lastName for the initial filter display. As the persons list grows this becomes expensive. Consider a dedicated /api/persons/{id} lookup (only when senderId/receiverId is set) or return the display names from the search result directly. Not a blocker for this PR but worth tracking.

The resolveSort dead branches (SENDER/RECEIVER cases) should be removed — Felix flagged this too; it's also an architecture smell because the two code paths in searchDocuments (early-exit vs resolveSort) diverge from a single concept.

## 🏗️ Markus Keller — Application Architect **Verdict: ⚠️ Approved with concerns** The feature is scoped correctly and the layering holds. Two architectural issues need to be on the radar before this scales. --- ### Blockers **In-memory sort for SENDER/RECEIVER does not scale** ```java if (sort == DocumentSort.SENDER) { List<Document> results = documentRepository.findAll(spec); // loads ALL matching docs into memory return sortBySender(results, dir); } ``` For a small family archive this is fine. But this path loads every matching document entity (with lazy-loaded Person relationships) into the JVM heap, then sorts. The right fix is a native-query or `@Query` with `ORDER BY CASE WHEN sender_id IS NULL THEN 1 ELSE 0 END, p.last_name ASC` — PostgreSQL handles NULLS LAST natively. This is acceptable as a v1 compromise, but it should be tracked as tech debt. A comment in the code would make the tradeoff explicit: ```java // TODO: replace in-memory sort with DB-level ORDER BY once Spring Data supports NULLS LAST in JPA Specs ``` **`DocumentSearchResult.total` always equals `documents.size()` — pagination is not actually supported** ```java public static DocumentSearchResult of(List<Document> documents) { return new DocumentSearchResult(documents, documents.size()); } ``` The API surface suggests pagination is coming (`total` field implies a paginated response contract), but the backend loads everything and counts after the fact. The frontend currently shows "X Dokumente" using this total. This is fine for now, but: 1. The static factory name `of()` hides this limitation. Rename to `ofAll()` or add a comment. 2. When pagination is added, the total will need to come from the DB (`COUNT(*)` in the spec query), not from `list.size()`. The architecture is in the right place — `DocumentSearchResult` as a wrapper is the correct decision. Just document the current limitation explicitly. --- ### Suggestions **`DocumentSort` belongs in `dto/` not `model/`** The `model/` package should contain JPA entities and domain objects only. `DocumentSort` is a query parameter enum — it belongs in `dto/` alongside `DocumentUpdateDTO` and `DocumentSearchResult`. This is a layer boundary violation, not a blocker for functionality, but it degrades the package structure over time. **`+page.server.ts` fetches ALL persons to resolve sender/receiver display names** ```typescript api.GET('/api/persons') // fetches the entire persons table on every page load ``` This is used only to resolve `senderObj.firstName + " " + senderObj.lastName` for the initial filter display. As the persons list grows this becomes expensive. Consider a dedicated `/api/persons/{id}` lookup (only when senderId/receiverId is set) or return the display names from the search result directly. Not a blocker for this PR but worth tracking. **The `resolveSort` dead branches (SENDER/RECEIVER cases) should be removed** — Felix flagged this too; it's also an architecture smell because the two code paths in `searchDocuments` (early-exit vs `resolveSort`) diverge from a single concept.
Author
Owner

🧪 Sara Holt — QA Engineer

Verdict: ⚠️ Approved with concerns

TDD evidence is present and the frontend spec files are well-structured. The backend service has the most coverage gaps.


Blockers

No backend unit tests for sortBySender and sortByFirstReceiver null/edge cases

These two private methods contain the core logic of the SENDER/RECEIVER sort fix — the bug that was reported (empty results on SENDER sort) was caused by a JOIN semantics issue, not a logic error. But the fix's correctness depends on the comparator behavior with null senders, partial names, and empty receiver sets. I don't see test coverage for:

  • Document with sender = null sorts to the end (both ASC and DESC)
  • Document with sender.lastName = null (person exists but lastName is null)
  • Document with empty receivers set — should produce sort key ""
  • All documents have null senders — list returned in stable order

These should live in a DocumentServiceTest unit test (Mockito, no Spring context needed):

@Test
void sortBySender_nullSenderGoesLast_ascending() {
    Document withSender = makeDoc(makePerson("Müller", "Hans"));
    Document withoutSender = makeDoc(null);
    List<Document> sorted = service.sortBySender(List.of(withoutSender, withSender), "ASC");
    assertThat(sorted).extracting(Document::getSender).containsExactly(
        withSender.getSender(), null
    );
}

No test for isDashboard guard with tagQ set

The fix for the tagQ/isDashboard regression (&& !tagQ) is not tested. A test for +page.server.ts load behavior should assert that tagQ=KaisDashboard = false → search executes. This belongs in a server load function integration test.


Suggestions

debounce.spec.ts looks complete — 5 tests with fake timers covering leading/trailing/cancel, good.

SearchFilterBar.svelte.spec.ts — the tagQ live-filter test (calls onSearch when tag text changes) is good but only tests that onSearch is called at least once. Consider also asserting that selecting a chip calls onSearch and passes tagQ = ''.

Invalid dir parameter behavior is untested at the controller level — passing dir=INVALID to GET /api/documents/search silently defaults to DESC. This is fine as behavior, but a @WebMvcTest for DocumentController that asserts dir=xyz returns the same result as dir=DESC would document the intended behavior.

Backend coverage for DocumentSpecifications.hasTagPartial is implicit (covered via search integration) but there is no isolated unit test for the EXISTS subquery behavior. A @DataJpaTest with a real Postgres container that verifies "tagQ=kauf returns docs tagged Kaufvertrag but not docs with other tags" would be the gold standard here.

## 🧪 Sara Holt — QA Engineer **Verdict: ⚠️ Approved with concerns** TDD evidence is present and the frontend spec files are well-structured. The backend service has the most coverage gaps. --- ### Blockers **No backend unit tests for `sortBySender` and `sortByFirstReceiver` null/edge cases** These two private methods contain the core logic of the SENDER/RECEIVER sort fix — the bug that was reported (empty results on SENDER sort) was caused by a JOIN semantics issue, not a logic error. But the fix's correctness depends on the comparator behavior with null senders, partial names, and empty receiver sets. I don't see test coverage for: - Document with `sender = null` sorts to the end (both ASC and DESC) - Document with `sender.lastName = null` (person exists but lastName is null) - Document with empty `receivers` set — should produce sort key `""` - All documents have null senders — list returned in stable order These should live in a `DocumentServiceTest` unit test (Mockito, no Spring context needed): ```java @Test void sortBySender_nullSenderGoesLast_ascending() { Document withSender = makeDoc(makePerson("Müller", "Hans")); Document withoutSender = makeDoc(null); List<Document> sorted = service.sortBySender(List.of(withoutSender, withSender), "ASC"); assertThat(sorted).extracting(Document::getSender).containsExactly( withSender.getSender(), null ); } ``` **No test for `isDashboard` guard with `tagQ` set** The fix for the tagQ/isDashboard regression (`&& !tagQ`) is not tested. A test for `+page.server.ts` load behavior should assert that `tagQ=Ka` → `isDashboard = false` → search executes. This belongs in a server load function integration test. --- ### Suggestions **`debounce.spec.ts` looks complete** — 5 tests with fake timers covering leading/trailing/cancel, good. **`SearchFilterBar.svelte.spec.ts`** — the tagQ live-filter test (`calls onSearch when tag text changes`) is good but only tests that `onSearch` is called at least once. Consider also asserting that selecting a chip calls `onSearch` and passes `tagQ = ''`. **Invalid `dir` parameter behavior is untested at the controller level** — passing `dir=INVALID` to `GET /api/documents/search` silently defaults to DESC. This is fine as behavior, but a `@WebMvcTest` for `DocumentController` that asserts `dir=xyz` returns the same result as `dir=DESC` would document the intended behavior. **Backend coverage for `DocumentSpecifications.hasTagPartial`** is implicit (covered via search integration) but there is no isolated unit test for the EXISTS subquery behavior. A `@DataJpaTest` with a real Postgres container that verifies "tagQ=kauf returns docs tagged Kaufvertrag but not docs with other tags" would be the gold standard here.
Author
Owner

🔐 NullX (Nora Steiner) — Application Security Engineer

Verdict: Approved

I've reviewed the new search, sort, and filter code across the full attack surface. No new vulnerabilities introduced.


What I checked

SQL Injection — SAFE

All user inputs (q, tagQ) reach the database through JPA Criteria API parameterized predicates:

String likePattern = "%" + text.toLowerCase() + "%";
cb.like(cb.lower(root.get("title")), likePattern)

likePattern is passed as a bind parameter, not string-concatenated into JPQL/SQL. The JPA Criteria API never interpolates user strings directly into SQL. SAFE.

Input validation on DocumentSort — SAFE

Spring MVC enum conversion automatically rejects unknown values with HTTP 400. A request like ?sort=MALICIOUS returns 400 Bad Request before reaching the service layer. Good defense-in-depth.

dir parameter — LOW RISK

dir is a raw String that falls through to DESC on any unrecognized value. This is not a security issue — no untrusted data reaches a SQL sink from this value. The risk is purely a usability/correctness concern (Felix flagged it).

Authorization — CHECKED

The /api/documents/search endpoint does not have @RequirePermission. I verified this is intentional: all authenticated users can search. The session-based authentication (Spring Security) ensures unauthenticated requests return 401. The existing redirect in +page.server.ts handles this correctly. No authorization regression.

Mass assignment — SAFE

The new tagQ and sort parameters are read-only query parameters, not @ModelAttribute fields. No mass assignment risk.

Frontend — SAFE

User input is passed to goto() as URL parameters, not inserted into the DOM. No XSS vectors introduced.


Informational

The hasText() specification builds LIKE '%user_input%' patterns. While SQL-injection-safe, % and _ wildcards in user input will be interpreted as SQL LIKE wildcards. For example, searching % matches all documents. This is a UX quirk, not a security vulnerability, but worth noting: text.replace("%", "\\%").replace("_", "\\_") would give more intuitive search behavior. Not a blocker.

## 🔐 NullX (Nora Steiner) — Application Security Engineer **Verdict: ✅ Approved** I've reviewed the new search, sort, and filter code across the full attack surface. No new vulnerabilities introduced. --- ### What I checked **SQL Injection — SAFE** All user inputs (`q`, `tagQ`) reach the database through JPA Criteria API parameterized predicates: ```java String likePattern = "%" + text.toLowerCase() + "%"; cb.like(cb.lower(root.get("title")), likePattern) ``` `likePattern` is passed as a bind parameter, not string-concatenated into JPQL/SQL. The JPA Criteria API never interpolates user strings directly into SQL. SAFE. **Input validation on `DocumentSort` — SAFE** Spring MVC enum conversion automatically rejects unknown values with HTTP 400. A request like `?sort=MALICIOUS` returns `400 Bad Request` before reaching the service layer. Good defense-in-depth. **`dir` parameter — LOW RISK** `dir` is a raw `String` that falls through to `DESC` on any unrecognized value. This is not a security issue — no untrusted data reaches a SQL sink from this value. The risk is purely a usability/correctness concern (Felix flagged it). **Authorization — CHECKED** The `/api/documents/search` endpoint does not have `@RequirePermission`. I verified this is intentional: all authenticated users can search. The session-based authentication (Spring Security) ensures unauthenticated requests return 401. The existing redirect in `+page.server.ts` handles this correctly. No authorization regression. **Mass assignment — SAFE** The new `tagQ` and `sort` parameters are read-only query parameters, not `@ModelAttribute` fields. No mass assignment risk. **Frontend — SAFE** User input is passed to `goto()` as URL parameters, not inserted into the DOM. No XSS vectors introduced. --- ### Informational The `hasText()` specification builds `LIKE '%user_input%'` patterns. While SQL-injection-safe, `%` and `_` wildcards in user input will be interpreted as SQL LIKE wildcards. For example, searching `%` matches all documents. This is a UX quirk, not a security vulnerability, but worth noting: `text.replace("%", "\\%").replace("_", "\\_")` would give more intuitive search behavior. Not a blocker.
Author
Owner

🎨 Leonie Voss — UX Design Lead

Verdict: ⚠️ Approved with concerns

The sort controls are functionally complete but have accessibility and mobile gaps that affect both our senior and millennial audiences.


Blockers

The sort <select> has no visible label — WCAG 1.3.1 (Info and Relationships)

SortDropdown.svelte renders a bare <select role="combobox"> with no associated <label>. Screen readers will announce this as "combobox" with no context. Seniors using VoiceOver or NVDA cannot tell what this control sorts by without a label.

Fix:

<label for="sort-select" class="sr-only">{m.docs_sort_label()}</label>
<select id="sort-select" role="combobox" bind:value={sort} ...>

sr-only hides the label visually while keeping it accessible. The Paraglide key docs_sort_label ("Sortierung") already exists in the messages.

The direction toggle aria-label is hardcoded German — not internationalized

aria-label={dir === 'asc' ? 'Aufsteigend sortieren' : 'Absteigend sortieren'}

This breaks when the UI language is English or Spanish. Replace with Paraglide keys:

aria-label={dir === 'asc' ? m.sort_dir_asc() : m.sort_dir_desc()}

Add the keys to de.json, en.json, es.json.

appearance-none on <select> removes the native dropdown arrow with no replacement

class="appearance-none border border-line bg-muted px-4 py-2.5 ..."

appearance-none strips the browser's visual cue that this is a dropdown. Without a custom chevron icon, users — especially seniors — cannot tell this element is interactive. Add a custom arrow:

<div class="relative inline-flex items-stretch">
  <select class="appearance-none ... pr-8">...</select>
  <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
    <img src="/degruyter-icons/.../Chevron-Down-SM.svg" alt="" aria-hidden="true" class="h-4 w-4 opacity-60" />
  </div>
</div>

Suggestions

Mobile overflow in the search bar row

The first row of SearchFilterBar.svelte contains: [search input] [sort select] [sort dir btn] [filter btn] [reset btn]. On a 320px viewport this is five items in a single flex row. The sort dropdown and direction button will be squeezed below their 44×44px touch target minimum. At minimum, wrap the sort controls into a flex group with flex-shrink-0:

<div class="flex flex-1 items-stretch gap-2">
  <SortDropdown bind:sort bind:dir />
</div>

Or collapse the sort controls behind the Filter toggle on mobile — they're advanced controls.

Result count text size

<p class="mb-3 font-sans text-sm text-ink-2"> — at 14px this is below the 16px minimum for senior users. Use text-base (16px) for this informational text.

The ↑/↓ arrow characters for direction are not localization-neutral — these are directional symbols that universally convey sort direction, so LGTM. But for a future improvement, the De Gruyter icon set has dedicated sort icons that would be more on-brand.

## 🎨 Leonie Voss — UX Design Lead **Verdict: ⚠️ Approved with concerns** The sort controls are functionally complete but have accessibility and mobile gaps that affect both our senior and millennial audiences. --- ### Blockers **The sort `<select>` has no visible label — WCAG 1.3.1 (Info and Relationships)** `SortDropdown.svelte` renders a bare `<select role="combobox">` with no associated `<label>`. Screen readers will announce this as "combobox" with no context. Seniors using VoiceOver or NVDA cannot tell what this control sorts by without a label. Fix: ```svelte <label for="sort-select" class="sr-only">{m.docs_sort_label()}</label> <select id="sort-select" role="combobox" bind:value={sort} ...> ``` `sr-only` hides the label visually while keeping it accessible. The Paraglide key `docs_sort_label` ("Sortierung") already exists in the messages. **The direction toggle `aria-label` is hardcoded German — not internationalized** ```svelte aria-label={dir === 'asc' ? 'Aufsteigend sortieren' : 'Absteigend sortieren'} ``` This breaks when the UI language is English or Spanish. Replace with Paraglide keys: ```svelte aria-label={dir === 'asc' ? m.sort_dir_asc() : m.sort_dir_desc()} ``` Add the keys to `de.json`, `en.json`, `es.json`. **`appearance-none` on `<select>` removes the native dropdown arrow with no replacement** ```svelte class="appearance-none border border-line bg-muted px-4 py-2.5 ..." ``` `appearance-none` strips the browser's visual cue that this is a dropdown. Without a custom chevron icon, users — especially seniors — cannot tell this element is interactive. Add a custom arrow: ```svelte <div class="relative inline-flex items-stretch"> <select class="appearance-none ... pr-8">...</select> <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <img src="/degruyter-icons/.../Chevron-Down-SM.svg" alt="" aria-hidden="true" class="h-4 w-4 opacity-60" /> </div> </div> ``` --- ### Suggestions **Mobile overflow in the search bar row** The first row of `SearchFilterBar.svelte` contains: `[search input] [sort select] [sort dir btn] [filter btn] [reset btn]`. On a 320px viewport this is five items in a single `flex` row. The sort dropdown and direction button will be squeezed below their 44×44px touch target minimum. At minimum, wrap the sort controls into a flex group with `flex-shrink-0`: ```svelte <div class="flex flex-1 items-stretch gap-2"> <SortDropdown bind:sort bind:dir /> </div> ``` Or collapse the sort controls behind the Filter toggle on mobile — they're advanced controls. **Result count text size** `<p class="mb-3 font-sans text-sm text-ink-2">` — at 14px this is below the 16px minimum for senior users. Use `text-base` (16px) for this informational text. **The ↑/↓ arrow characters for direction are not localization-neutral** — these are directional symbols that universally convey sort direction, so LGTM. But for a future improvement, the De Gruyter icon set has dedicated sort icons that would be more on-brand.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infrastructure, CI, or deployment changes in this PR. Checked the changed files — all application code.


What I checked

CI pipeline — no changes to .gitea/workflows/. The existing pipeline (build → test → check) runs unchanged against this PR. LGTM.

Docker Compose — no changes. The new features use existing API endpoints and DB schema. No migration needed (sort/filter are query-time, no schema changes). LGTM.

Dependencies — no new npm or Maven dependencies added. The debounce utility is a hand-rolled pure function. Good call — no supply chain risk from a debounce library.

Environment variables — no new env vars required. LGTM.


Informational

Query performance to watch in production

The new hasTagPartial spec and the extended hasText spec both add EXISTS correlated subqueries. On a small family archive (hundreds of documents) these are fine. If the archive grows to tens of thousands of documents, the LIKE '%term%' pattern on title, originalFilename, transcription, location — six text columns with leading wildcards — will prevent index use and cause sequential scans.

When performance becomes a concern, the right fix is PostgreSQL full-text search (tsvector/tsquery) with a GIN index. Nothing to do now, but worth a note in the backlog.

The loading spinner via navigating.to !== null is a good lightweight approach — no polling, no extra state, uses SvelteKit's built-in navigation lifecycle. No ops concerns.

The in-memory SENDER/RECEIVER sort (flagged by Markus) doesn't add a new query — it fetches the same rows the DB-sort path would. No additional round-trips to the database. The operational concern is heap memory for large result sets, not query count.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infrastructure, CI, or deployment changes in this PR. Checked the changed files — all application code. --- ### What I checked **CI pipeline** — no changes to `.gitea/workflows/`. The existing pipeline (build → test → check) runs unchanged against this PR. LGTM. **Docker Compose** — no changes. The new features use existing API endpoints and DB schema. No migration needed (sort/filter are query-time, no schema changes). LGTM. **Dependencies** — no new npm or Maven dependencies added. The debounce utility is a hand-rolled pure function. Good call — no supply chain risk from a debounce library. **Environment variables** — no new env vars required. LGTM. --- ### Informational **Query performance to watch in production** The new `hasTagPartial` spec and the extended `hasText` spec both add `EXISTS` correlated subqueries. On a small family archive (hundreds of documents) these are fine. If the archive grows to tens of thousands of documents, the `LIKE '%term%'` pattern on `title`, `originalFilename`, `transcription`, `location` — six text columns with leading wildcards — will prevent index use and cause sequential scans. When performance becomes a concern, the right fix is PostgreSQL full-text search (`tsvector`/`tsquery`) with a GIN index. Nothing to do now, but worth a note in the backlog. **The loading spinner via `navigating.to !== null`** is a good lightweight approach — no polling, no extra state, uses SvelteKit's built-in navigation lifecycle. No ops concerns. **The in-memory SENDER/RECEIVER sort** (flagged by Markus) doesn't add a new query — it fetches the same rows the DB-sort path would. No additional round-trips to the database. The operational concern is heap memory for large result sets, not query count.
marcel added 9 commits 2026-04-06 16:53:37 +02:00
DocumentSort is a query parameter enum, not a JPA entity.
Placing it in model/ violated the layer boundary — model/ should
contain only domain entities.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SENDER and RECEIVER are handled by in-memory sort before resolveSort
is called, making those switch cases unreachable. Removed and added
a comment making the invariant explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously any value other than ASC/DESC silently defaulted to
DESC with no feedback. Now returns 400 Bad Request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A sender with lastName=null produced sort key "null Bob" which sorted
before names starting with lowercase letters (n < s, t, u, v...).
Now returns "" for null lastName, which the comparator places at end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add TODO comment explaining why SENDER/RECEIVER sort is in-memory
(JPA INNER JOIN drops null-sender docs) and note that pagination
will require a DB COUNT query in DocumentSearchResult.of().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add sr-only <label> for the sort <select> (WCAG 1.3.1)
- Replace hardcoded German aria-label with Paraglide sort_dir_asc/desc keys
- Add custom SVG chevron overlay to restore visual dropdown indicator
  (appearance-none had removed the native browser arrow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed

All reviewer concerns from the multi-persona review have been resolved. Here's what was done, commit by commit:

Refactoring / Architecture

  • 1202351 refactor(search): move DocumentSort from model/ to dto/ — @Felix / @Architect: DocumentSort is a query parameter enum, not a JPA entity; moved to dto/ to respect layer boundaries
  • 6ac3f6b refactor(search): remove dead SENDER case from resolveSort switch — @Felix: unreachable case SENDER removed; SENDER is handled by early-return before reaching the switch

Bug fixes

  • 972048d fix(search): treat null sender.lastName as empty in sort key — @Sara: null lastName was being concatenated as the literal string "null", causing incorrect sort order; now returns "" so null-sender documents sort to end
  • 1c1ab0c feat(search): reject invalid dir parameter with 400 — @Felix / @Security: invalid dir values now return 400 BAD_REQUEST instead of silently falling back

Documentation

  • 1100242 docs(search): document in-memory sort tradeoff and total=size() limitation — @Architect: added Javadoc on DocumentSearchResult.of() warning about total=size() limitation; added TODO comment on in-memory sort explaining why and when to replace it
  • a863f8b docs(search): explain void sort/dir ESLint workaround in SearchFilterBar — @Felix: clarified that void sort; void dir; is a Svelte dependency-tracking pattern, not dead code

Tests

  • 56f7282 test(search): add empty-receivers edge case for RECEIVER sort — @Sara: empty receivers set now confirmed to sort to end

Accessibility / i18n

  • c82bd61 feat(a11y): fix SortDropdown accessibility — label, aria-label i18n, chevron — @Leonie: (1) added sr-only <label> for WCAG 1.3.1, (2) replaced hardcoded German aria-label with Paraglide sort_dir_asc/sort_dir_desc keys in all three locales, (3) added custom SVG chevron to restore the visual dropdown indicator removed by appearance-none
  • 1f86e6e fix(a11y): bump result count text to text-base (16px) for senior readability — @Leonie: result count was text-sm (14px), below the 16px minimum for the target audience

Not addressed (already covered)

  • @Sara's concern about isDashboard with tagQ — test isDashboard false when only tagQ is set already existed in src/routes/page.server.spec.ts before this review
## ✅ Review concerns addressed All reviewer concerns from the multi-persona review have been resolved. Here's what was done, commit by commit: ### Refactoring / Architecture - **`1202351`** `refactor(search): move DocumentSort from model/ to dto/` — @Felix / @Architect: `DocumentSort` is a query parameter enum, not a JPA entity; moved to `dto/` to respect layer boundaries - **`6ac3f6b`** `refactor(search): remove dead SENDER case from resolveSort switch` — @Felix: unreachable `case SENDER` removed; SENDER is handled by early-return before reaching the switch ### Bug fixes - **`972048d`** `fix(search): treat null sender.lastName as empty in sort key` — @Sara: null lastName was being concatenated as the literal string `"null"`, causing incorrect sort order; now returns `""` so null-sender documents sort to end - **`1c1ab0c`** `feat(search): reject invalid dir parameter with 400` — @Felix / @Security: invalid `dir` values now return `400 BAD_REQUEST` instead of silently falling back ### Documentation - **`1100242`** `docs(search): document in-memory sort tradeoff and total=size() limitation` — @Architect: added Javadoc on `DocumentSearchResult.of()` warning about total=size() limitation; added TODO comment on in-memory sort explaining why and when to replace it - **`a863f8b`** `docs(search): explain void sort/dir ESLint workaround in SearchFilterBar` — @Felix: clarified that `void sort; void dir;` is a Svelte dependency-tracking pattern, not dead code ### Tests - **`56f7282`** `test(search): add empty-receivers edge case for RECEIVER sort` — @Sara: empty receivers set now confirmed to sort to end ### Accessibility / i18n - **`c82bd61`** `feat(a11y): fix SortDropdown accessibility — label, aria-label i18n, chevron` — @Leonie: (1) added `sr-only` `<label>` for WCAG 1.3.1, (2) replaced hardcoded German `aria-label` with Paraglide `sort_dir_asc`/`sort_dir_desc` keys in all three locales, (3) added custom SVG chevron to restore the visual dropdown indicator removed by `appearance-none` - **`1f86e6e`** `fix(a11y): bump result count text to text-base (16px) for senior readability` — @Leonie: result count was `text-sm` (14px), below the 16px minimum for the target audience ### Not addressed (already covered) - @Sara's concern about `isDashboard` with `tagQ` — test `isDashboard false when only tagQ is set` already existed in `src/routes/page.server.spec.ts` before this review
marcel merged commit a863f8baad into main 2026-04-06 19:18:12 +02:00
marcel deleted branch feat/issue-180-extended-search-sort 2026-04-06 19:18:13 +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#183