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:
@@ -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)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user