diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java new file mode 100644 index 00000000..0009a162 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java @@ -0,0 +1,69 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; +import org.raddatz.familienarchiv.model.PersonMention; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Transcription-domain consumer of {@link PersonDisplayNameChangedEvent}. When + * Person.getDisplayName() flips during a rename, this listener rewrites every + * transcription block whose sidecar references the renamed person — both the + * literal "@OldName" inside block.text and the displayName carried in the + * {@link PersonMention} entries. + * + *

Synchronous on purpose: the rename and the propagation must commit as one + * transaction so a half-applied rewrite never reaches the archive. If the + * archive grows past tens of thousands of blocks, switch to + * {@code @TransactionalEventListener(AFTER_COMMIT) + @Async} — one annotation + * change. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class PersonMentionPropagationListener { + + private final TranscriptionBlockRepository blockRepository; + private final PersonService personService; + + @EventListener + @Transactional + public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) { + if (!personService.existsById(event.personId())) { + log.warn("Skipping mention propagation for non-existent personId {}", event.personId()); + return; + } + + List blocks = + blockRepository.findByMentionedPersons_PersonId(event.personId()); + if (blocks.isEmpty()) { + return; + } + + String oldNeedle = "@" + event.oldDisplayName(); + String newNeedle = "@" + event.newDisplayName(); + + for (TranscriptionBlock block : blocks) { + if (block.getText() != null) { + block.setText(block.getText().replace(oldNeedle, newNeedle)); + } + for (PersonMention mention : block.getMentionedPersons()) { + if (mention.getPersonId().equals(event.personId())) { + mention.setDisplayName(event.newDisplayName()); + } + } + } + + blockRepository.saveAll(blocks); + + log.info("Propagated rename {} → {} across {} block(s) for person {}", + event.oldDisplayName(), event.newDisplayName(), blocks.size(), event.personId()); + } +} 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 c83050ac..191c8530 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -50,6 +50,10 @@ public class PersonService { .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); } + public boolean existsById(UUID id) { + return personRepository.existsById(id); + } + public List findCorrespondents(UUID personId, String q) { if (q != null && !q.isBlank()) { return personRepository.findCorrespondentsWithFilter(personId, q); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java new file mode 100644 index 00000000..a07de40e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java @@ -0,0 +1,98 @@ +package org.raddatz.familienarchiv.service; + +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.model.Document; +import org.raddatz.familienarchiv.model.DocumentAnnotation; +import org.raddatz.familienarchiv.model.DocumentStatus; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent; +import org.raddatz.familienarchiv.model.PersonMention; +import org.raddatz.familienarchiv.model.TranscriptionBlock; +import org.raddatz.familienarchiv.repository.AnnotationRepository; +import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.raddatz.familienarchiv.repository.PersonRepository; +import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class PersonMentionPropagationListenerTest { + + @Autowired TranscriptionBlockRepository blockRepository; + @Autowired DocumentRepository documentRepository; + @Autowired AnnotationRepository annotationRepository; + @Autowired PersonRepository personRepository; + @Autowired EntityManager em; + + private PersonMentionPropagationListener listener; + private PersonService personService; + + private UUID documentId; + private UUID annotationId; + + @BeforeEach + void setUp() { + personService = mock(PersonService.class); + when(personService.existsById(any())).thenReturn(true); + listener = new PersonMentionPropagationListener(blockRepository, personService); + + Document doc = documentRepository.save(Document.builder() + .title("Letter").originalFilename("letter.pdf") + .status(DocumentStatus.UPLOADED).build()); + documentId = doc.getId(); + DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder() + .documentId(documentId).pageNumber(1) + .x(0.1).y(0.2).width(0.3).height(0.4) + .color("#00C7B1").build()); + annotationId = annotation.getId(); + } + + private TranscriptionBlock saveBlock(String text, List mentions) { + return blockRepository.saveAndFlush(TranscriptionBlock.builder() + .annotationId(annotationId).documentId(documentId) + .text(text).sortOrder(0) + .mentionedPersons(mentions).build()); + } + + private UUID savedPersonId(String firstName, String lastName) { + Person p = personRepository.save(Person.builder() + .firstName(firstName).lastName(lastName).build()); + return p.getId(); + } + + @Test + void rewritesTextAndSidecar_whenSingleBlockReferencesRenamedPerson() { + UUID personId = savedPersonId("Auguste", "Raddatz"); + TranscriptionBlock saved = saveBlock( + "Liebe Tante @Auguste Raddatz, danke für den Brief.", + List.of(new PersonMention(personId, "Auguste Raddatz"))); + em.clear(); + + listener.onPersonDisplayNameChanged( + new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")); + blockRepository.flush(); + em.clear(); + + TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow(); + assertThat(reloaded.getText()).isEqualTo("Liebe Tante @Augusta Raddatz, danke für den Brief."); + assertThat(reloaded.getMentionedPersons()) + .extracting(PersonMention::getDisplayName) + .containsExactly("Augusta Raddatz"); + } +}