feat(transcription): PersonMentionPropagationListener rewrites blocks on rename

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-28 20:25:16 +02:00
parent a2c633c5de
commit 4d288589fa
3 changed files with 171 additions and 0 deletions

View File

@@ -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.
*
* <p>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<TranscriptionBlock> 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());
}
}

View File

@@ -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<Person> findCorrespondents(UUID personId, String q) {
if (q != null && !q.isBlank()) {
return personRepository.findCorrespondentsWithFilter(personId, q);

View File

@@ -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<PersonMention> 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");
}
}