feat(person): add paged search, confirm and delete to PersonService

PersonService.search maps a PersonFilter to the paired slice/count repository
queries and returns a PersonSearchResult with a server-side total. confirmPerson
clears the provisional flag (the state transition behind PATCH /confirm).
deletePerson detaches sender/receiver document references before the hard delete
so it cannot orphan an FK.

Refs #667

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-27 13:30:14 +02:00
parent a24764e58a
commit ec357ac13c
4 changed files with 205 additions and 0 deletions

View File

@@ -194,6 +194,12 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
@Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true) @Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true)
void reassignSender(@Param("source") UUID source, @Param("target") UUID target); void reassignSender(@Param("source") UUID source, @Param("target") UUID target);
// Used by deletePerson: detach a deleted person from documents they sent, so the hard
// delete cannot orphan a documents.sender_id FK (the column is nullable).
@Modifying
@Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true)
void reassignSenderToNull(@Param("source") UUID source);
@Modifying @Modifying
@Query(value = """ @Query(value = """
INSERT INTO document_receivers (document_id, person_id) INSERT INTO document_receivers (document_id, person_id)

View File

@@ -45,6 +45,51 @@ public class PersonService {
return personRepository.findTopByDocumentCount(limit); return personRepository.findTopByDocumentCount(limit);
} }
/**
* Filtered, paginated directory query. The slice and the total are derived from one
* shared WHERE clause (see {@link PersonRepository#FILTER_WHERE}) so totalElements can
* never drift from the rendered page. {@code type} is passed as the enum name because the
* native query compares against the string column.
*/
public PersonSearchResult search(PersonFilter filter, int page, int size, String q) {
String type = filter.type() == null ? null : filter.type().name();
String query = (q == null || q.isBlank()) ? null : q.trim();
int offset = page * size;
List<PersonSummaryDTO> items = personRepository.findByFilter(
type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(),
filter.readerDefault(), query, size, offset);
long total = personRepository.countByFilter(
type, filter.familyOnly(), filter.hasDocuments(), filter.provisional(),
filter.readerDefault(), query);
return PersonSearchResult.paged(items, page, size, total);
}
/**
* Clears the {@code provisional} flag — a deliberate state transition exposed as
* {@code PATCH /api/persons/{id}/confirm}, never as a mass-assignable DTO field (CWE-915).
*/
@Transactional
public Person confirmPerson(UUID id) {
Person person = getById(id);
person.setProvisional(false);
return personRepository.save(person);
}
/**
* Hard-deletes a person used by triage. Detaches the person from any documents they
* sent (nulls sender_id) and from any received-document references first, so the delete
* cannot orphan an FK and fail with a 500.
*/
@Transactional
public void deletePerson(UUID id) {
getById(id);
personRepository.reassignSenderToNull(id);
personRepository.deleteReceiverReferences(id);
personRepository.deleteById(id);
}
public Person getById(UUID id) { public Person getById(UUID id) {
return personRepository.findById(id) return personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));

View File

@@ -63,4 +63,53 @@ class PersonServiceIntegrationTest {
assertThat(result.getFirstName()).isEqualTo("Clara"); assertThat(result.getFirstName()).isEqualTo("Clara");
assertThat(result.getLastName()).isEqualTo("Cram"); assertThat(result.getLastName()).isEqualTo("Cram");
} }
// ─── #667: confirm round-trip + reader-default semantics ──────────────────
@Test
void search_readerDefault_hidesProvisionalZeroDocumentPerson() {
personRepository.save(Person.builder()
.firstName("Unbe").lastName("Staetigt").provisional(true).build());
PersonSearchResult result = personService.search(PersonFilter.cleanDefault(), 0, 50, null);
assertThat(result.items()).noneMatch(p -> p.getLastName().equals("Staetigt"));
assertThat(result.totalElements()).isEqualTo(result.items().size());
}
@Test
void search_showAll_includesProvisionalZeroDocumentPerson() {
personRepository.save(Person.builder()
.firstName("Unbe").lastName("Staetigt").provisional(true).build());
PersonSearchResult result = personService.search(PersonFilter.showAll(), 0, 50, null);
assertThat(result.items()).anyMatch(p -> p.getLastName().equals("Staetigt"));
}
@Test
void confirmPerson_clearsProvisional_andShowAllTreatsItAsConfirmed() {
Person provisional = personRepository.save(Person.builder()
.firstName("Bald").lastName("Bestaetigt").provisional(true).build());
personService.confirmPerson(provisional.getId());
Person reloaded = personRepository.findById(provisional.getId()).orElseThrow();
assertThat(reloaded.isProvisional()).isFalse();
PersonSearchResult showAll = personService.search(PersonFilter.showAll(), 0, 50, null);
assertThat(showAll.items())
.filteredOn(p -> p.getId().equals(provisional.getId()))
.allMatch(p -> !p.isProvisional());
}
@Test
void deletePerson_removesPerson() {
Person target = personRepository.save(Person.builder()
.firstName("Weg").lastName("Person").provisional(true).build());
personService.deletePerson(target.getId());
assertThat(personRepository.findById(target.getId())).isEmpty();
}
} }

View File

@@ -87,6 +87,111 @@ class PersonServiceTest {
verify(personRepository, never()).findAllWithDocumentCount(); verify(personRepository, never()).findAllWithDocumentCount();
} }
// ─── #667: search (filter + pagination) ──────────────────────────────────
@Test
void search_returnsPagedResult_withTotalsFromCountQuery() {
PersonFilter filter = PersonFilter.cleanDefault();
when(personRepository.countByFilter(null, null, null, null, true, null)).thenReturn(120L);
when(personRepository.findByFilter(null, null, null, null, true, null, 50, 0))
.thenReturn(List.of());
PersonSearchResult result = personService.search(filter, 0, 50, null);
assertThat(result.totalElements()).isEqualTo(120L);
assertThat(result.pageNumber()).isEqualTo(0);
assertThat(result.pageSize()).isEqualTo(50);
assertThat(result.totalPages()).isEqualTo(3); // ceil(120 / 50)
}
@Test
void search_passesTypeAsEnumName_toRepository() {
PersonFilter filter = PersonFilter.builder().type(PersonType.INSTITUTION).build();
when(personRepository.countByFilter("INSTITUTION", null, null, null, false, null)).thenReturn(0L);
when(personRepository.findByFilter("INSTITUTION", null, null, null, false, null, 50, 0))
.thenReturn(List.of());
personService.search(filter, 0, 50, null);
verify(personRepository).findByFilter("INSTITUTION", null, null, null, false, null, 50, 0);
}
@Test
void search_computesOffset_fromPageAndSize() {
PersonFilter filter = PersonFilter.showAll();
when(personRepository.countByFilter(null, null, null, null, false, null)).thenReturn(0L);
when(personRepository.findByFilter(null, null, null, null, false, null, 20, 40))
.thenReturn(List.of());
personService.search(filter, 2, 20, null); // offset = page * size = 40
verify(personRepository).findByFilter(null, null, null, null, false, null, 20, 40);
}
@Test
void search_trimsBlankQueryToNull() {
PersonFilter filter = PersonFilter.showAll();
when(personRepository.countByFilter(null, null, null, null, false, null)).thenReturn(0L);
when(personRepository.findByFilter(null, null, null, null, false, null, 50, 0))
.thenReturn(List.of());
personService.search(filter, 0, 50, " ");
verify(personRepository).findByFilter(null, null, null, null, false, null, 50, 0);
}
// ─── #667: confirmPerson ──────────────────────────────────────────────────
@Test
void confirmPerson_clearsProvisionalFlag() {
UUID id = UUID.randomUUID();
Person provisional = Person.builder().id(id).firstName("Inferred").lastName("Person").provisional(true).build();
when(personRepository.findById(id)).thenReturn(Optional.of(provisional));
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
Person result = personService.confirmPerson(id);
assertThat(result.isProvisional()).isFalse();
verify(personRepository).save(argThat(p -> !p.isProvisional()));
}
@Test
void confirmPerson_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(personRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.confirmPerson(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(404);
}
// ─── #667: deletePerson ───────────────────────────────────────────────────
@Test
void deletePerson_deletes_whenPersonExists() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Weg").lastName("Person").build();
when(personRepository.findById(id)).thenReturn(Optional.of(person));
personService.deletePerson(id);
verify(personRepository).reassignSenderToNull(id);
verify(personRepository).deleteReceiverReferences(id);
verify(personRepository).deleteById(id);
}
@Test
void deletePerson_throwsNotFound_whenMissing() {
UUID id = UUID.randomUUID();
when(personRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> personService.deletePerson(id))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getStatus().value())
.isEqualTo(404);
}
// ─── createPerson ───────────────────────────────────────────────────────── // ─── createPerson ─────────────────────────────────────────────────────────
@Test @Test