diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java index 24c3de42..59921087 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Person; @@ -31,7 +32,7 @@ public class PersonController { private final DocumentService documentService; @GetMapping - public ResponseEntity> getPersons(@RequestParam(required = false) String q) { + public ResponseEntity> getPersons(@RequestParam(required = false) String q) { return ResponseEntity.ok(personService.findAll(q)); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java new file mode 100644 index 00000000..d31539ac --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/PersonSummaryDTO.java @@ -0,0 +1,19 @@ +package org.raddatz.familienarchiv.dto; + +import java.util.UUID; + +/** + * Projection returned by the /api/persons list endpoint. + * Includes document count to avoid N+1 queries in the UI. + * Uses interface projection for compatibility with native queries. + */ +public interface PersonSummaryDTO { + UUID getId(); + String getFirstName(); + String getLastName(); + String getAlias(); + Integer getBirthYear(); + Integer getDeathYear(); + String getNotes(); + long getDocumentCount(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index 3c1411b1..6e2bd033 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.model.Person; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -31,6 +32,33 @@ public interface PersonRepository extends JpaRepository { // Exact first+last name match, used for filename-based sender lookup Optional findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName); + // --- PersonSummaryDTO with document count --- + + @Query(value = """ + SELECT p.id, p.first_name AS firstName, p.last_name AS lastName, + p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, + (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 + ORDER BY p.last_name ASC, p.first_name ASC + """, + nativeQuery = true) + List findAllWithDocumentCount(); + + @Query(value = """ + SELECT p.id, p.first_name AS firstName, p.last_name AS lastName, + p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes, + (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 + WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%')) + OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%')) + OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%')) + ORDER BY p.last_name ASC, p.first_name ASC + """, + nativeQuery = true) + List searchWithDocumentCount(@Param("query") String query); + // --- Correspondent queries --- @Query(value = """ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index 5bb41788..db5249a1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; @@ -22,11 +23,11 @@ public class PersonService { private final PersonRepository personRepository; - public List findAll(String q) { + public List findAll(String q) { if (q != null && !q.isBlank()) { - return personRepository.searchByName(q); + return personRepository.searchWithDocumentCount(q); } - return personRepository.findAllByOrderByLastNameAscFirstNameAsc(); + return personRepository.findAllWithDocumentCount(); } public Person getById(UUID id) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index 44950607..bafb209f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -21,6 +21,8 @@ import java.util.Collections; import java.util.List; import java.util.UUID; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @@ -57,14 +59,27 @@ class PersonControllerTest { @Test @WithMockUser void getPersons_delegatesQueryParam_toService() throws Exception { - Person person = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build(); - when(personService.findAll("Hans")).thenReturn(List.of(person)); + PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller"); + when(personService.findAll("Hans")).thenReturn(List.of(dto)); mockMvc.perform(get("/api/persons").param("q", "Hans")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].firstName").value("Hans")); } + private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) { + return new PersonSummaryDTO() { + public java.util.UUID getId() { return UUID.randomUUID(); } + public String getFirstName() { return firstName; } + public String getLastName() { return lastName; } + public String getAlias() { return null; } + public Integer getBirthYear() { return null; } + public Integer getDeathYear() { return null; } + public String getNotes() { return null; } + public long getDocumentCount() { return 0; } + }; + } + // ─── GET /api/persons/{id} ──────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java index 69696fdb..20811c72 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/PersonRepositoryTest.java @@ -11,6 +11,8 @@ import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabas import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; import org.springframework.context.annotation.Import; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; + import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import java.util.List; @@ -288,6 +290,76 @@ class PersonRepositoryTest { assertThat(targetCount).isEqualTo(1); // no duplicate } + // ─── Phase 3.2: findAllWithDocumentCount ────────────────────────────────── + + @Test + void findAllWithDocumentCount_includesDocumentCountAsSenderAndReceiver() { + Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build()); + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build()); + + // Walter sends 2 docs to Anna (Anna receives 2) + documentRepository.save(Document.builder() + .title("Brief 1").originalFilename("b1.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(anna)).build()); + documentRepository.save(Document.builder() + .title("Brief 2").originalFilename("b2.pdf") + .status(DocumentStatus.UPLOADED) + .sender(walter).receivers(Set.of(anna)).build()); + // Anna also sends 1 doc to Walter + documentRepository.save(Document.builder() + .title("Brief 3").originalFilename("b3.pdf") + .status(DocumentStatus.UPLOADED) + .sender(anna).receivers(Set.of(walter)).build()); + + List result = personRepository.findAllWithDocumentCount(); + + PersonSummaryDTO walterSummary = result.stream() + .filter(p -> p.getId().equals(walter.getId())).findFirst().orElseThrow(); + PersonSummaryDTO annaSummary = result.stream() + .filter(p -> p.getId().equals(anna.getId())).findFirst().orElseThrow(); + + assertThat(walterSummary.getDocumentCount()).isEqualTo(3); // sent 2, received 1 + assertThat(annaSummary.getDocumentCount()).isEqualTo(3); // sent 1, received 2 + } + + @Test + void findAllWithDocumentCount_returnsZero_whenPersonHasNoDocuments() { + Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build()); + + List result = personRepository.findAllWithDocumentCount(); + + PersonSummaryDTO soloSummary = result.stream() + .filter(p -> p.getId().equals(solo.getId())).findFirst().orElseThrow(); + assertThat(soloSummary.getDocumentCount()).isEqualTo(0); + } + + @Test + void searchWithDocumentCount_filtersAndIncludesCount() { + Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build()); + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build()); + + documentRepository.save(Document.builder() + .title("Brief").originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED) + .sender(hans).receivers(Set.of(anna)).build()); + + List result = personRepository.searchWithDocumentCount("Hans"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getFirstName()).isEqualTo("Hans"); + assertThat(result.get(0).getDocumentCount()).isEqualTo(1); + } + + @Test + void searchWithDocumentCount_isCaseInsensitive() { + personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build()); + + List result = personRepository.searchWithDocumentCount("hans"); + + assertThat(result).hasSize(1); + } + // ─── deleteReceiverReferences ───────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index b9208d75..e0638a00 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.PersonSummaryDTO; import org.raddatz.familienarchiv.dto.PersonUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.Person; @@ -52,32 +53,32 @@ class PersonServiceTest { @Test void findAll_returnsAll_whenQueryIsNull() { - List expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build()); - when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected); + List expected = List.of(); + when(personRepository.findAllWithDocumentCount()).thenReturn(expected); assertThat(personService.findAll(null)).isEqualTo(expected); - verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc(); - verify(personRepository, never()).searchByName(any()); + verify(personRepository).findAllWithDocumentCount(); + verify(personRepository, never()).searchWithDocumentCount(any()); } @Test void findAll_returnsAll_whenQueryIsBlank() { - List expected = List.of(); - when(personRepository.findAllByOrderByLastNameAscFirstNameAsc()).thenReturn(expected); + List expected = List.of(); + when(personRepository.findAllWithDocumentCount()).thenReturn(expected); assertThat(personService.findAll(" ")).isEqualTo(expected); - verify(personRepository).findAllByOrderByLastNameAscFirstNameAsc(); - verify(personRepository, never()).searchByName(any()); + verify(personRepository).findAllWithDocumentCount(); + verify(personRepository, never()).searchWithDocumentCount(any()); } @Test void findAll_searchesByName_whenQueryIsNonBlank() { - List expected = List.of(Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Müller").build()); - when(personRepository.searchByName("Anna")).thenReturn(expected); + List expected = List.of(); + when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected); assertThat(personService.findAll("Anna")).isEqualTo(expected); - verify(personRepository).searchByName("Anna"); - verify(personRepository, never()).findAllByOrderByLastNameAscFirstNameAsc(); + verify(personRepository).searchWithDocumentCount("Anna"); + verify(personRepository, never()).findAllWithDocumentCount(); } // ─── createPerson ─────────────────────────────────────────────────────────