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);
+ }
}