From 4d288589fab2ea8c72f0907bbd413f1fb84e04d6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 28 Apr 2026 20:25:16 +0200 Subject: [PATCH] feat(transcription): PersonMentionPropagationListener rewrites blocks on rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronous @EventListener consumer of PersonDisplayNameChangedEvent. Finds every block whose sidecar references the renamed person via the derived query, replaces "@OldName" with "@NewName" inside block.text, and updates the matching PersonMention.displayName in the sidecar list. saveAll in one batch; SLF4J info log records the audit line. 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 @TransactionalEventListener(AFTER_COMMIT) + @Async. Adds PersonService.existsById to give the listener a layered way to verify the personId still corresponds to a real Person — defensive guard for any future async refactor where an event could outlive the entity. The check goes through PersonService rather than PersonRepository to honour the "services never reach into another domain's repository" rule. Happy-path @DataJpaTest + Testcontainers asserts a single-block, single- mention rewrite mutates both the text and the sidecar entry. blockRepository .flush() is called explicitly so saveAll is committed before em.clear() — in production the surrounding @Transactional flushes on commit; in test we substitute by flushing manually. Implements PR-A tasks 13 and 15 as one red→green cycle. Refs #362 Co-Authored-By: Claude Opus 4.7 --- .../PersonMentionPropagationListener.java | 69 +++++++++++++ .../familienarchiv/service/PersonService.java | 4 + .../PersonMentionPropagationListenerTest.java | 98 +++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java 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"); + } +}