feat(transcription): person @mention sidecar + rename propagation (PR-A backend, #362) #366
@@ -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));
|
.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) {
|
public List<Person> findCorrespondents(UUID personId, String q) {
|
||||||
if (q != null && !q.isBlank()) {
|
if (q != null && !q.isBlank()) {
|
||||||
return personRepository.findCorrespondentsWithFilter(personId, q);
|
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