refactor(backend): delete rename-propagation listener and its infrastructure

PersonMentionPropagationListener rewrites @DisplayName tokens on person rename.
Under the new design, displayName is archival (what the transcriber typed), so
the listener would corrupt transcriptions rather than correct them.

Deletes PersonMentionPropagationListener, PersonDisplayNameChangedEvent, and the
optimistic-lock catch path in PersonService.updatePerson. Removes PERSON_RENAME_CONFLICT
from ErrorCode and all tests that exercised the now-deleted code path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 14:58:18 +02:00
parent bc58d77f2c
commit 2d19ca7244
8 changed files with 6 additions and 490 deletions

View File

@@ -15,11 +15,6 @@ public enum ErrorCode {
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
/** A concurrent edit on a referenced transcription block prevented the rename
* from committing (optimistic-lock conflict). The whole rename rolls back; the
* client should refetch and retry. 409 */
PERSON_RENAME_CONFLICT,
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,

View File

@@ -1,23 +0,0 @@
package org.raddatz.familienarchiv.model;
import java.util.UUID;
/**
* Published by PersonService when a save changes Person.getDisplayName() — i.e.
* any mutation to the fields that DisplayNameFormatter consumes (title,
* firstName, lastName). Listeners on the transcription side rewrite block text
* and sidecar entries that reference the old name.
*
* <p>This is the first custom application event in the codebase. The previous
* only listener (OcrTrainingService.recoverOrphanedRuns) listens to Spring's
* built-in ApplicationReadyEvent. Future cross-domain decoupling should follow
* the same shape: record-typed event in model/, listener in the consuming
* domain's service/ package, synchronous @EventListener inside the publisher's
* transaction unless the workload genuinely needs to defer.
*/
public record PersonDisplayNameChangedEvent(
UUID personId,
String oldDisplayName,
String newDisplayName
) {
}

View File

@@ -26,5 +26,6 @@ public class PersonMention {
@Size(max = 200)
@Column(name = "display_name", nullable = false, length = 200)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
// Archival: the text the transcriber typed after @. Never updated on person rename.
private String displayName;
}

View File

@@ -1,77 +0,0 @@
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.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 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.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class PersonMentionPropagationListener {
private final TranscriptionBlockRepository blockRepository;
@EventListener
@Transactional // Joins publisher's transaction — async switch requires @TransactionalEventListener(AFTER_COMMIT)
public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) {
List<TranscriptionBlock> blocks =
blockRepository.findByPersonIdWithMentionsFetched(event.personId());
if (blocks.isEmpty()) {
return;
}
String oldNeedle = "@" + event.oldDisplayName();
String newNeedle = "@" + event.newDisplayName();
Pattern boundary = Pattern.compile(
Pattern.quote(oldNeedle) + "(?![\\p{L}0-9\\-]| (?=\\p{Lu}))");
String replacement = Matcher.quoteReplacement(newNeedle);
for (TranscriptionBlock block : blocks) {
rewriteBlockText(block, boundary, replacement);
for (PersonMention mention : block.getMentionedPersons()) {
if (mention.getPersonId().equals(event.personId())) {
mention.setDisplayName(event.newDisplayName());
}
}
}
blockRepository.saveAllAndFlush(blocks);
log.info("Propagated rename {} → {} across {} block(s) for person {}",
event.oldDisplayName(), event.newDisplayName(), blocks.size(), event.personId());
}
// Match @OldName only at a token boundary: not followed by a letter/digit/hyphen
// (catches @Hans-Peter when renaming Hans) AND not followed by " <Uppercase>"
// (catches @Hans Müller when renaming the single-name @Hans). False negatives —
// e.g. "@Hans Bekam" where Bekam is sentence-initial — are accepted as the
// conservative trade-off; the alternative (corruption) is irrecoverable.
private void rewriteBlockText(TranscriptionBlock block, Pattern boundary, String replacement) {
if (block.getText() != null) {
block.setText(boundary.matcher(block.getText()).replaceAll(replacement));
}
}
}

View File

@@ -1,7 +1,6 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -13,14 +12,11 @@ import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,7 +30,6 @@ public class PersonService {
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
private final ApplicationEventPublisher eventPublisher;
public List<PersonSummaryDTO> findAll(String q) {
if (q == null) {
@@ -161,7 +156,6 @@ public class PersonService {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
String oldDisplayName = person.getDisplayName();
person.setPersonType(dto.getPersonType());
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
@@ -170,17 +164,7 @@ public class PersonService {
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
Person saved = personRepository.save(person);
String newDisplayName = saved.getDisplayName();
if (!Objects.equals(oldDisplayName, newDisplayName)) {
try {
eventPublisher.publishEvent(new PersonDisplayNameChangedEvent(id, oldDisplayName, newDisplayName));
} catch (OptimisticLockingFailureException e) {
throw DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT,
"A referenced transcription block was modified concurrently — rename rolled back");
}
}
return saved;
return personRepository.save(person);
}
@Transactional