From a24764e58a1cbefe8d8fd268da74378bc694185e Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 13:27:39 +0200 Subject: [PATCH] feat(person): add filter-aware paged repository queries Add PersonSearchResult (mirrors DocumentSearchResult shape) and PersonFilter records, plus paired findByFilter/countByFilter native queries sharing one WHERE clause so the rendered page and totalElements can never drift. Filters (type, familyOnly, hasDocuments, provisional, readerDefault, q) each disable via a null/false param. Tested against real Postgres via Testcontainers. Refs #667 Co-Authored-By: Claude Opus 4.7 --- .../familienarchiv/person/PersonFilter.java | 36 +++++ .../person/PersonRepository.java | 55 +++++++ .../person/PersonSearchResult.java | 36 +++++ .../person/PersonRepositoryTest.java | 152 ++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/person/PersonFilter.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/person/PersonSearchResult.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonFilter.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonFilter.java new file mode 100644 index 00000000..bc41214a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonFilter.java @@ -0,0 +1,36 @@ +package org.raddatz.familienarchiv.person; + +import lombok.Builder; + +/** + * The reader/triage filter set for the persons directory, threaded as one value through + * {@code PersonController -> PersonService -> PersonRepository}. Each field is nullable: + * null means "do not constrain on this dimension". + * + * + */ +@Builder +public record PersonFilter( + PersonType type, + Boolean familyOnly, + Boolean hasDocuments, + Boolean provisional, + boolean readerDefault +) { + /** The unconstrained "show all" filter (transcriber view, no reader restriction). */ + public static PersonFilter showAll() { + return PersonFilter.builder().readerDefault(false).build(); + } + + /** The clean reader default: familyMember OR documentCount > 0, no other constraints. */ + public static PersonFilter cleanDefault() { + return PersonFilter.builder().readerDefault(true).build(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index 940e34a2..75b265d0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -88,6 +88,61 @@ public interface PersonRepository extends JpaRepository { nativeQuery = true) List findTopByDocumentCount(@Param("limit") int limit); + // --- #667: filter-aware paged directory --- + // + // The slice query and the count query below MUST keep an IDENTICAL WHERE clause so the + // rendered page and totalElements can never drift. Every filter is nullable: a null param + // disables that predicate via the `:param IS NULL OR …` idiom. `readerDefault` (a plain + // boolean) restricts to "familyMember OR has documents"; the explicit filters AND on top. + // documentCount is recomputed inline (not via the SELECT alias) because WHERE cannot + // reference a computed alias. All params are named — no string concatenation, no injection. + String FILTER_WHERE = """ + WHERE (CAST(:type AS text) IS NULL OR p.person_type = CAST(:type AS text)) + AND (:familyOnly = FALSE OR :familyOnly IS NULL OR p.family_member = TRUE) + AND (:hasDocuments = FALSE OR :hasDocuments IS NULL OR ( + (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id)) > 0) + AND (:provisional IS NULL OR p.provisional = :provisional) + AND (:readerDefault = FALSE OR ( + p.family_member = TRUE OR ( + (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id)) > 0)) + AND (CAST(:query AS text) IS NULL OR + LOWER(CONCAT(COALESCE(p.first_name,''),' ',p.last_name)) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%')) + OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%')) + OR LOWER(p.alias) LIKE LOWER(CONCAT('%',CAST(:query AS text),'%'))) + """; + + @Query(value = """ + SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName, + p.person_type AS personType, + p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, + p.family_member AS familyMember, p.provisional AS provisional, + (SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id) + + (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount + FROM persons p + """ + FILTER_WHERE + """ + ORDER BY p.last_name ASC, p.first_name ASC + LIMIT :limit OFFSET :offset + """, + nativeQuery = true) + List findByFilter(@Param("type") String type, + @Param("familyOnly") Boolean familyOnly, + @Param("hasDocuments") Boolean hasDocuments, + @Param("provisional") Boolean provisional, + @Param("readerDefault") boolean readerDefault, + @Param("query") String query, + @Param("limit") int limit, + @Param("offset") int offset); + + @Query(value = "SELECT COUNT(*) FROM persons p " + FILTER_WHERE, nativeQuery = true) + long countByFilter(@Param("type") String type, + @Param("familyOnly") Boolean familyOnly, + @Param("hasDocuments") Boolean hasDocuments, + @Param("provisional") Boolean provisional, + @Param("readerDefault") boolean readerDefault, + @Param("query") String query); + // --- Correspondent queries --- @Query(value = """ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonSearchResult.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonSearchResult.java new file mode 100644 index 00000000..7f8c8e93 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonSearchResult.java @@ -0,0 +1,36 @@ +package org.raddatz.familienarchiv.person; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * Paged result for the /api/persons list endpoint. + * + *

Hand-written to mirror {@code document/DocumentSearchResult} field-for-field so the + * frontend sees one paged shape across the app. Deliberately NOT Spring {@code Page} + * (unstable serialized shape across Spring versions, noisy in OpenAPI) and deliberately + * NOT a reuse of the document DTO (would couple two feature modules — duplication beats + * coupling here). + */ +public record PersonSearchResult( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + List items, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + long totalElements, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + int pageNumber, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + int pageSize, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + int totalPages +) { + /** + * Paged factory: derives {@code totalPages} from the full match count and the page size. + * A zero count yields zero pages so the frontend hides the pagination control. + */ + public static PersonSearchResult paged(List slice, int pageNumber, int pageSize, long totalElements) { + int totalPages = pageSize == 0 ? 0 : (int) ((totalElements + pageSize - 1) / pageSize); + return new PersonSearchResult(slice, totalElements, pageNumber, pageSize, totalPages); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index 2de9f69f..60e63084 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -505,4 +505,156 @@ class PersonRepositoryTest { .filter(p -> p.getId().equals(provisional.getId())).findFirst().orElseThrow(); assertThat(summary.isProvisional()).isTrue(); } + + // ─── #667: filter-aware paged slice + paired COUNT (Postgres-only) ──────── + // The slice query (findByFilter) and the count query (countByFilter) MUST share one + // WHERE clause so totalElements can never drift from the rendered page. These tests run + // against real Postgres because the slice ORDER BY uses a computed alias that fails on H2. + + private void seedDirectoryFixture() { + // Register family member, no documents — visible by reader default (familyMember) + personRepository.save(Person.builder().firstName("Karl").lastName("Register").familyMember(true).build()); + // Person with one document — visible by reader default (documentCount > 0) + Person hasDoc = personRepository.save(Person.builder().firstName("Doku").lastName("Person").build()); + documentRepository.save(Document.builder().title("B").originalFilename("b.pdf") + .status(DocumentStatus.UPLOADED).sender(hasDoc).build()); + // Provisional, zero-document, non-family — hidden by reader default + personRepository.save(Person.builder().firstName("Unbe").lastName("Staetigt").provisional(true).build()); + // An institution with no documents, non-family, non-provisional + personRepository.save(Person.builder().lastName("Verlag GmbH").personType(PersonType.INSTITUTION).build()); + } + + @Test + void findByFilter_readerDefault_returnsOnlyFamilyOrWithDocuments() { + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + null, null, null, null, true, null, 50, 0); + + assertThat(slice).extracting(PersonSummaryDTO::getLastName) + .containsExactlyInAnyOrder("Register", "Person"); + } + + @Test + void countByFilter_readerDefault_matchesSliceSize() { + seedDirectoryFixture(); + + long count = personRepository.countByFilter(null, null, null, null, true, null); + + assertThat(count).isEqualTo(2); + } + + @Test + void findByFilter_showAll_returnsEveryone() { + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + null, null, null, null, false, null, 50, 0); + + assertThat(slice).hasSize(4); + } + + @Test + void findByFilter_typeInstitution_returnsOnlyInstitutions() { + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + "INSTITUTION", null, null, null, false, null, 50, 0); + + assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH"); + } + + @Test + void findByFilter_familyOnly_returnsOnlyFamilyMembers() { + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + null, true, null, null, false, null, 50, 0); + + assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Register"); + } + + @Test + void findByFilter_hasDocuments_returnsOnlyPersonsWithDocuments() { + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + null, null, true, null, false, null, 50, 0); + + assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Person"); + } + + @Test + void findByFilter_provisionalTrue_returnsOnlyProvisional() { + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + null, null, null, true, false, null, 50, 0); + + assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Staetigt"); + } + + @Test + void findByFilter_combinedFilters_andTogether() { + seedDirectoryFixture(); + // family + has-documents → intersection is empty (Register has no docs, Doku is not family) + List slice = personRepository.findByFilter( + null, true, true, null, false, null, 50, 0); + + assertThat(slice).isEmpty(); + } + + @Test + void findByFilter_query_combinesWithFilters() { + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + null, null, null, null, false, "Verlag", 50, 0); + + assertThat(slice).extracting(PersonSummaryDTO::getLastName).containsExactly("Verlag GmbH"); + } + + @Test + void findByFilter_pageBeyondRange_returnsEmptySlice() { + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + null, null, null, null, false, null, 50, 999 * 50); + + assertThat(slice).isEmpty(); + } + + @Test + void findByFilter_respectsPageSize() { + seedDirectoryFixture(); + + List firstPage = personRepository.findByFilter( + null, null, null, null, false, null, 2, 0); + List secondPage = personRepository.findByFilter( + null, null, null, null, false, null, 2, 2); + + assertThat(firstPage).hasSize(2); + assertThat(secondPage).hasSize(2); + assertThat(firstPage).extracting(PersonSummaryDTO::getId) + .doesNotContainAnyElementsOf(secondPage.stream().map(PersonSummaryDTO::getId).toList()); + } + + @Test + void countByFilter_typeInstitution_matchesSlice() { + seedDirectoryFixture(); + + long count = personRepository.countByFilter("INSTITUTION", null, null, null, false, null); + + assertThat(count).isEqualTo(1); + } + + @Test + void findByFilter_projectsDocumentCount() { + seedDirectoryFixture(); + + List slice = personRepository.findByFilter( + null, null, true, null, false, null, 50, 0); + + assertThat(slice.get(0).getDocumentCount()).isEqualTo(1); + } }