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