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 1beebcc3..940e34a2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -32,6 +32,9 @@ public interface PersonRepository extends JpaRepository { // Lookup by full alias string, used during ODS mass import Optional findByAliasIgnoreCase(String alias); + // Lookup by the normalizer person_id, used for idempotent canonical re-import (Phase 3). + Optional findBySourceRef(String sourceRef); + // Exact first+last name match, used for filename-based sender lookup Optional findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index 89b11ef3..d02dcef8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -115,6 +115,69 @@ public class PersonService { }); } + /** + * Idempotent upsert keyed on {@code sourceRef} (the normalizer person_id) for the + * canonical importer (Phase 3, ADR-025). On first import the canonical fields are + * written verbatim. On re-import the human-edit-preserve precedence applies: + * a non-blank existing field is never overwritten, and {@code provisional} never + * flips back to true once a human has confirmed the person. + */ + @Transactional + public Person upsertBySourceRef(PersonUpsertCommand cmd) { + return personRepository.findBySourceRef(cmd.sourceRef()) + .map(existing -> personRepository.save(mergeCanonical(existing, cmd))) + .orElseGet(() -> fromCanonical(cmd)); + } + + private Person fromCanonical(PersonUpsertCommand cmd) { + Person person = personRepository.save(Person.builder() + .sourceRef(cmd.sourceRef()) + .firstName(blankToNull(cmd.firstName())) + .lastName(cmd.lastName()) + .notes(blankToNull(cmd.notes())) + .birthYear(cmd.birthYear()) + .deathYear(cmd.deathYear()) + .familyMember(cmd.familyMember()) + .personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType()) + .provisional(cmd.provisional()) + .build()); + String maiden = blankToNull(cmd.maidenName()); + if (maiden != null) { + int nextSortOrder = aliasRepository.findMaxSortOrder(person.getId()) + 1; + aliasRepository.save(PersonNameAlias.builder() + .person(person) + .lastName(maiden) + .type(PersonNameAliasType.MAIDEN_NAME) + .sortOrder(nextSortOrder) + .build()); + } + return person; + } + + private Person mergeCanonical(Person existing, PersonUpsertCommand cmd) { + existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName())); + existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName())); + existing.setNotes(preferHuman(existing.getNotes(), cmd.notes())); + if (existing.getBirthYear() == null) existing.setBirthYear(cmd.birthYear()); + if (existing.getDeathYear() == null) existing.setDeathYear(cmd.deathYear()); + if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) { + existing.setPersonType(cmd.personType()); + } + // provisional is monotonic: once a human confirms a person (false) it never reverts. + if (existing.isProvisional()) { + existing.setProvisional(cmd.provisional()); + } + return existing; + } + + private static String preferHuman(String existing, String canonical) { + return (existing == null || existing.isBlank()) ? blankToNull(canonical) : existing; + } + + private static String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } + @Transactional public Person createPerson(String firstName, String lastName, String alias) { Person person = Person.builder() diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpsertCommand.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpsertCommand.java new file mode 100644 index 00000000..63864ab6 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonUpsertCommand.java @@ -0,0 +1,24 @@ +package org.raddatz.familienarchiv.person; + +import lombok.Builder; + +/** + * Importer → {@link PersonService} command for an idempotent upsert keyed on + * {@code sourceRef} (the normalizer's stable person_id). Carries only the canonical + * fields the importer owns; the service applies the human-edit-preserve precedence + * (see ADR-025): non-blank existing fields are never overwritten, and {@code provisional} + * never flips back to true once a human has confirmed a person. + */ +@Builder +public record PersonUpsertCommand( + String sourceRef, + String firstName, + String lastName, + String maidenName, + String notes, + Integer birthYear, + Integer deathYear, + boolean familyMember, + PersonType personType, + boolean provisional +) {} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java new file mode 100644 index 00000000..75a6381a --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonImportUpsertTest.java @@ -0,0 +1,131 @@ +package org.raddatz.familienarchiv.person; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PersonImportUpsertTest { + + @Mock PersonRepository personRepository; + @Mock PersonNameAliasRepository aliasRepository; + @InjectMocks PersonService personService; + + @Test + void upsertBySourceRef_insertsNewPerson_whenSourceRefUnknown() { + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").firstName("Clara").lastName("Cram") + .personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.getSourceRef()).isEqualTo("clara-cram"); + assertThat(result.getFirstName()).isEqualTo("Clara"); + assertThat(result.getLastName()).isEqualTo("Cram"); + assertThat(result.isProvisional()).isFalse(); + } + + @Test + void upsertBySourceRef_updatesInPlace_whenSourceRefExists() { + Person existing = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram") + .firstName("Clara").lastName("Cram").build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").firstName("Clara").lastName("Cram") + .notes("Updated note").personType(PersonType.PERSON).provisional(false).build(); + + personService.upsertBySourceRef(cmd); + + verify(personRepository).save(argThat(p -> p.getId().equals(existing.getId()))); + verify(personRepository, never()).save(argThat(p -> p.getId() == null)); + } + + @Test + void upsertBySourceRef_preservesHumanEditedNonBlankFields() { + // A human renamed the maiden-name register person and added notes in-app. + Person humanEdited = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram") + .firstName("Klara").lastName("Cram-Müller").notes("Verified by Marcel").build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(humanEdited)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").firstName("Clara").lastName("Cram") + .notes("Auto note").personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + // Human edits survive the re-import. + assertThat(result.getFirstName()).isEqualTo("Klara"); + assertThat(result.getLastName()).isEqualTo("Cram-Müller"); + assertThat(result.getNotes()).isEqualTo("Verified by Marcel"); + } + + @Test + void upsertBySourceRef_fillsOnlyBlankFields_onReimport() { + Person existing = Person.builder() + .id(UUID.randomUUID()).sourceRef("clara-cram") + .firstName("Clara").lastName("Cram").notes(null).build(); + when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("clara-cram").firstName("Clara").lastName("Cram") + .notes("Nichte von Herbert").personType(PersonType.PERSON).provisional(false).build(); + + Person result = personService.upsertBySourceRef(cmd); + + // Blank field gets filled by canonical value. + assertThat(result.getNotes()).isEqualTo("Nichte von Herbert"); + } + + @Test + void upsertBySourceRef_neverFlipsProvisionalBackToTrue_onceHumanConfirmed() { + // A human confirmed this provisional importer-created person (provisional -> false). + Person confirmed = Person.builder() + .id(UUID.randomUUID()).sourceRef("schwester-hanni") + .firstName(null).lastName("Schwester Hanni").provisional(false).build(); + when(personRepository.findBySourceRef("schwester-hanni")).thenReturn(Optional.of(confirmed)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("schwester-hanni").lastName("Schwester Hanni") + .personType(PersonType.PERSON).provisional(true).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.isProvisional()).isFalse(); + } + + @Test + void upsertBySourceRef_setsProvisionalTrue_forNewProvisionalPerson() { + when(personRepository.findBySourceRef("noise-geschirr")).thenReturn(Optional.empty()); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + PersonUpsertCommand cmd = PersonUpsertCommand.builder() + .sourceRef("noise-geschirr").lastName("Tante Tüten") + .personType(PersonType.PERSON).provisional(true).build(); + + Person result = personService.upsertBySourceRef(cmd); + + assertThat(result.isProvisional()).isTrue(); + } +}