feat(persons): audit + complete person-merge flow for all data domains #368
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
The person merge flow (
PUT /api/persons/{id}/merge, person detail page) was built before several newer data domains existed. This issue audits every domain that holds person references and specifies what the merge must do in each one, so a full pass can close the gaps before the feature is considered production-safe.Triggered by: PR-A #366 introduces
transcription_block_mentioned_persons(a sidecar table +block.textinline references) that the current merge almost certainly does not handle.Data domains that hold person references
documents.sender_iddocument_receivers (document_id, person_id)person_aliases.person_id(if table exists)person_relationships (person1_id, person2_id)transcription_block_mentioned_persons.person_iddisplay_nameto target's display name; rewrite@SourceNameinblock.text(see below)app_users→Personassociation (if any)The @mention gap (introduced by PR-A #366)
What must happen
When source person S is merged into target person T:
Sidecar reassignment — every row in
transcription_block_mentioned_personswhereperson_id = S.idmust becomeperson_id = T.idwithdisplay_name = T.getDisplayName().Deduplication — if a block already mentions T, reassigning S's sidecar row would create two rows with the same
(block_id, T.id)— a violation of theUNIQUEconstraint added in V57. These must be deduplicated: keep the T row (update itsdisplay_nameif needed), delete the S row.block.text rewrite — if
S.getDisplayName() ≠ T.getDisplayName(), every occurrence of@SDisplayNameinblock.textmust be replaced with@TDisplayName. Use the same boundary-anchored regex already inPersonMentionPropagationListener.rewriteBlockText— do not duplicate the logic.Recommended approach
Introduce a
MergePersonMentionsStep(or reuse the propagation listener) called from within the merge transaction:Document deduplication edge case
A document can list the same person as a receiver only once. If document D has both source S and target T as receivers:
document_receiversfor D; T's entry stays.Same logic applies if source is the sender and target is also a receiver (or vice versa) — no action needed there since sender and receivers are independent.
Family relationship edge case
If a relationship row has
person1_id = Sandperson2_id = T(or vice versa), that row represents a relationship between the two persons being merged. After merge they are the same person — the row must be deleted, not reassigned.All other relationship rows involving S are reassigned to T. Check for duplicate relationship types after reassignment (e.g. two "Vater" relationships for T+same third person) and deduplicate.
AppUser link guard
If the source Person is linked to an AppUser account, silently deleting the Person would leave the account without a profile. The merge endpoint must:
source.idis referenced by anyapp_usersrow.400 / DomainException(PERSON_HAS_USER_ACCOUNT)with a clear error message.Atomicity requirement
The entire merge — document reassignment, alias merge, relationship reassignment, sidecar rewrite, text rewrite, source deletion — must execute in a single
@Transactionalboundary. If any step fails, the whole operation rolls back. No partial merges.Acceptance criteria
Open questions
person_aliases?person_relationships?Related
PersonMentionPropagationListener— containsrewriteBlockTextthat must be reused, not duplicatedOQ-3 resolved — product decision:
Historical Persons (the archive subjects — Auguste Raddatz, Hans Müller, etc.) and AppUsers (the family members who operate the archive) are strictly separate domains. A Person record never has an associated AppUser account.
Consequence: The AppUser link guard in the merge flow is not needed. Remove it from the implementation scope. The merge endpoint can proceed directly to document/alias/relationship/sidecar reassignment without a pre-flight account check.
Code audit — existing merge scope confirmed
Read
PersonService.mergePersons(line 187). Current implementation:What it already handles correctly:
What it does NOT handle (and the feature still works today only because these are post-#366):
❌ @mention sidecar — the actual gap
transcription_block_mentioned_personshas no FK onperson_id(V56, by design). WhendeleteById(sourceId)fires:person_id = sourceIdare not cascade-deleted — they become orphanedblock.textstill contains@S.displayName— never rewritten to@T.displayNameperson_id = sourceIdand get a 404 for every merged person's mentionFix needed in
mergePersons, beforedeleteById:findByPersonIdWithMentionsFetched(sourceId)targetId→ delete source's sidecar row; else → update sidecar row toperson_id = targetId,display_name = target.getDisplayName()source.getDisplayName() ≠ target.getDisplayName()→ callrewriteBlockText(already inPersonMentionPropagationListener) — do not duplicate the regexblockRepository.saveAllAndFlush(blocks)This runs inside the existing
@Transactionalboundary so it rolls back with everything else.Out of scope for this issue (existing behaviour, not a regression):
🏛️ Markus Keller (@mkeller) — Application Architect
Observations
mergePersonsruns inside@Transactional— the atomicity requirement is already met structurally. The new mention-sidecar step must stay inside this same transactional boundary; it will, as long as it is called beforedeleteById.PersonRelationshipRepositoryhas no merge-aware query. The migration V54 usesON DELETE CASCADEon bothperson_idandrelated_person_id— this means all relationships of the source person are silently cascade-deleted whendeleteById(sourceId)fires. No transfer happens at all. This is a data-loss gap not explicitly called out in the issue's "code audit" comment (which punted aliases and relationships to a future issue), but the cascade behavior makes the gap worse than a "pre-existing limitation" — it is an invisible silent drop, not a documented non-transfer.PersonServicecurrently only injectsPersonRepositoryandPersonNameAliasRepository. Adding the sidecar step will require injectingTranscriptionBlockRepositoryfrom thedocument.transcriptionpackage. This is a cross-domain dependency:persondomain callingdocumentdomain's repository directly, which violates the layering rule (services call other domain's service, not repository). The correct path is: injectTranscriptionService(or introduce aTranscriptionMentionServicecallable method) — or, if the mention logic is cohesive enough, move it into a newPersonMentionMergeServicethat lives in the transcription package and is called fromPersonService.rewriteBlockTextis referenced in the issue but does not appear in the codebase —PersonMentionPropagationListenerdoes not exist yet (it is introduced by PR-A #366 which is still open). The implementation of this issue should not proceed until the listener method is merged and available, or should include it as part of this issue's scope.mergePersonsmethod throwsResponseStatusExceptionfor the source==target guard, but throwsDomainExceptionfor not-found. This is inconsistent with the project's error-handling convention (services should useDomainExceptioneverywhere;ResponseStatusExceptionis acceptable only in controllers). The issue spec's acceptance criteria includesINVALID_INPUTas the ErrorCode for this case — fixing the inconsistency is part of the spec.@PostMapping("/{id}/merge")in the controller and called asPOSTfrom the frontend. The issue title and original code audit sayPUT /api/persons/{id}/merge. These must align — pick one HTTP verb and keep it consistent across controller, frontend, and any API test files.Recommendations
TranscriptionService(e.g.,transferMentions(UUID sourcePersonId, UUID targetPersonId)) and injectTranscriptionServiceintoPersonService. This keeps the domain boundary clean and the sidecar logic co-located with the other mention code.reassignRelationships(UUID sourceId, UUID targetId)native-SQL helper toPersonRelationshipRepositorythat: (1) updates rows whereperson_id = sourcetotarget, (2) updates rows whererelated_person_id = sourcetotarget, (3) deletes any row where both sides resolve to the same person after reassignment. All three in one@Modifying @Transactionalmethod. This must execute beforedeleteById— do not rely on the cascade, as the cascade silently drops all edges rather than reassigning them.ResponseStatusExceptioninmergePersons— replace withDomainException.badRequest(ErrorCode.INVALID_INPUT, "Source and target must differ")so the frontend can map it to an i18n string. This requires addingINVALID_INPUTtoErrorCode.java, mirroring it inerrors.ts, and adding i18n keys — the issue spec already calls this out.POSTis acceptable for a non-idempotent destructive operation.rewriteBlockTextutility must be in the codebase before this issue can reuse it.Open Decisions
👨💻 Felix Brandt (@felixbrandt) — Fullstack Developer
Observations
mergePersonsdoes three things: validates, reassigns documents, and deletes. The new sidecar step will make it four. Each step should be extracted into a named private method so the orchestrator reads as a sequence of intent-revealing calls. The current 8-line method is at the growth limit.sourceId == targetIdguard throwsResponseStatusExceptionwhile the not-found guards throwDomainException— two different exception types in the same method. The convention isDomainExceptionfrom services. Fix this as part of the issue.mergePersonslacks@Transactional— wait, looking again at line 174, it does have@Transactional. Good. The new steps will inherit the boundary automatically.PersonMentionis an@Embeddablecollected via@ElementCollection(fetch = FetchType.EAGER). ThefindByPersonIdWithMentionsFetchedquery inTranscriptionBlockRepositoryalready exists and returns blocks with mentions pre-joined. The implementation can use this query directly.targetId, remove the source'sPersonMentionfrom the list; otherwise update the source entry'spersonIdanddisplayName. SincementionedPersonsis aList<PersonMention>(not aSet), the comparison must usepersonIdfield equality, not object identity.PersonMentionhas noequals/hashCodebeyond Lombok@Datadefaults (field equality).personIdanddisplayNameare both included in equality. When searching for an existing target mention, compare bypersonIdfield only — a match onpersonIdwith a differentdisplayNameis still a duplicate that should be merged, not kept.+page.server.tshardcodes a German error string:'Bitte eine Zielperson auswählen.'— this should be an i18n key viam.person_merge_select_target()or similar. Not introduced by this issue but worth noting./persons/${targetPersonId}. The target person page should now show the transferred mentions and aliases — no frontend change needed for the redirect, but the person detail page must already render mentions correctly (depends on PR-B).Recommendations
private void transferMentions(Person source, Person target)inPersonService(or delegate toTranscriptionService). The orchestratingmergePersonsthen reads:removeIfon the list to drop the source entry when a target entry already exists, or update in-place with areplaceAll-style loop. Keep it under 10 lines; extract a helper if it grows.ResponseStatusExceptionin the source==target guard withDomainException.badRequest(ErrorCode.INVALID_INPUT, "Source and target must not be the same")before this PR merges — the issue spec requires it and the test for it already asserts onResponseStatusException, so the test needs updating too.@Transactionalexplicitly tomergePersons(it has it already) and add a comment above the sidecar step:// Must execute before deleteById — sidecar has no FK, orphans on deleteso future readers understand the ordering constraint.mergePersons_reassignsDocumentsAndDeletesSourceshould be extended with a mock expectation for the newfindByPersonIdWithMentionsFetchedcall and the subsequent save.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
@RequirePermission(Permission.WRITE_ALL)in the controller. This is verified in the code — no finding here.UUID.fromString(targetIdStr)directly. IftargetIdStris not a valid UUID (e.g., a path-traversal string or garbage input), this throws an uncheckedIllegalArgumentExceptionthat bypasses theGlobalExceptionHandler's structured error mapping. The user gets a 500 instead of a 400, and the stack trace may leak in logs. Low severity, but the fix is a one-liner.ResponseStatusException: No security impact, but inconsistent with the structured error convention.DomainExceptioncarries anErrorCodethat the frontend maps to i18n —ResponseStatusExceptionmay leak the German error string"Quelle und Ziel dürfen nicht identisch sein"directly to the API consumer.person_id(by design, per V56 comment): This was a deliberate architectural choice to allow soft degradation when a Person is deleted. The merge step must explicitly clean up orphaned sidecar rows — it cannot rely on any cascade. The issue spec calls this out correctly. Verify in the implementation that the cleanup happens inside the transaction boundary so no window exists where sidecar rows reference a deleted person UUID.rewriteBlockTextlogic described is boundary-anchored regex replacement on stored text. No injection risk from the replacement itself. However, confirm that the regex pattern used inrewriteBlockTextusesPattern.quote()or equivalent to prevent a person's display name containing regex metacharacters (e.g.,Auguste (Müller)) from becoming an injection into the regex engine. Severity: Medium if unmitigated.WRITE_ALLuser — it should appear in the audit log. Without it, there is no way to reconstruct who mergedAuguste AltintoAuguste Neuand when. The existingAuditServicein theaudit/package is the right hook.Recommendations
UUID.fromString(targetIdStr)in the controller in a try-catch, returning a 400:rewriteBlockText(from PR-A #366) usesPattern.quote(displayName)when constructing the regex. If it does not, add it — a person name likeAnna (Braun)would corrupt the match without quoting.mergePersonsafter all steps succeed but before the method returns:auditService.log(MERGE_PERSON, sourceId, "merged into " + targetId). This requires injectingAuditService— already a pattern in other services.Open Decisions
rewriteBlockText: WhetherPattern.quote()is already applied depends on the implementation in PR-A #366, which is not yet merged. The implementer should verify this and add it if absent — it cannot be assumed safe.🧪 Sara Holt (@saraholt) — QA Engineer & Test Strategist
Observations
PersonServiceTestcover: source==target guard, source not found, target not found, and the happy-path document reassignment. They use Mockito +@ExtendWith(MockitoExtension.class)— correct layer. However, none of the new behaviors have test scaffolding yet.mergePersons_reassignsDocumentsAndDeletesSourcewill need to be extended (or a new test added) to mockTranscriptionBlockRepository.findByPersonIdWithMentionsFetchedand verify the sidecar update path. Currently thePersonServicedoes not injectTranscriptionBlockRepository, so any new mock must be added once the dependency is wired.(block_id, person_id)row that violates theUNIQUEconstraint from V57, causing a 500 at runtime. This path must have a unit test before the PR merges.source.getDisplayName() != target.getDisplayName()) also has no test coverage. The regex behavior (boundary anchoring, case sensitivity, partial match avoidance) cannot be verified without a test.PersonServiceTestis pure Mockito — there is no@SpringBootTestor Testcontainers integration test formergePersons. A partial-failure scenario (e.g., unique constraint violation mid-merge) cannot be verified without a real PostgreSQL instance.ON DELETE CASCADE) means the existing integration test suite may pass even though relationships are silently dropped — no assertion exists that checks relationship counts before and after merge.Recommendations
mergePersons_transfersMentions_whenSourceHasMentions— verifiesfindByPersonIdWithMentionsFetchedis called and blocks are saved with updatedpersonId.mergePersons_deduplicatesMentions_whenTargetAlreadyMentionedInBlock— verifies the source sidecar row is removed, not doubled.mergePersons_rewritesBlockText_whenDisplayNamesDiffer— verifies block text is rewritten.mergePersons_doesNotRewriteBlockText_whenDisplayNamesAreEqual— verifies no unnecessary text modification.PersonMergeIntegrationTestusing Testcontainers (postgres:16-alpine) covering:frontend/e2e/for the merge flow: navigate to a person's edit page, pick a merge target, confirm, assert redirect to target person's detail page.merge_rollsBack_whenUniqueConstraintViolatedMidTransaction— this documents the atomicity requirement that cannot be verified at the Mockito layer.Open Decisions
PersonMergeIntegrationTestbe a@SpringBootTest(full context) or a@DataJpaTest(JPA layer only)? Given thatmergePersonsnow crosses domain boundaries (person + transcription), a full@SpringBootTestis simpler to wire correctly, though slower. The trade-off is CI time vs. setup complexity.📋 Elicit — Requirements Engineer
Observations
person_aliases?) —Person.nameAliaseshasCascadeType.ALL, orphanRemoval = true. WhendeleteById(sourceId)fires, JPA cascades deletion to all aliases. Aliases are not transferred, they are deleted. OQ-2 (does merge handleperson_relationships?) — V54 usesON DELETE CASCADEon both FK columns. Relationships are also deleted, not transferred. Both OQs should be closed with these answers in the issue, since an implementer reading only the issue body would not find them.PersonMentionPropagationListener.rewriteBlockText, which is introduced by PR-A #366 (not yet merged). The issue does not explicitly block on #366. This is a hidden dependency that will cause a compilation failure if the implementer starts work before #366 lands.PersonMergePanel.svelteonly mentions that the source person will be deleted. It does not mention that @mentions in transcription blocks will be rewritten, or that any person with this name in existing transcription text will change. For a user who typed@Auguste Altsix months ago, seeing that text silently change to@Auguste Neucould be alarming. The UI should communicate what the merge affects.findByPersonIdWithMentionsFetched+saveAllAndFlushloop runs inside a single transaction. No size guard or async fallback is specified. For the current archive scale this is acceptable, but it is worth documenting as a known constraint.Recommendations
Blocks on: #366link in the issue so the dependency is visible in Gitea's UI.person_merge_warningi18n key (currently used inPersonMergePanel.svelte) describing that @mentions in transcription blocks will be updated: e.g., "Alle @-Erwähnungen von [Quelle] in Transkriptionen werden auf [Ziel] umgeschrieben."Open Decisions
CascadeType.ALLmeans the current code actively deletes them — a user who merges two persons loses the source's aliases without warning, which is a data-loss regression, not a missing feature.🎨 Leonie Voss (@leonievoss) — UI/UX Design Lead
Observations
PersonMergePanel.sveltefollows the card pattern correctly (rounded-sm border border-line bg-surface p-6 shadow-sm) with the danger zone styled inborder-red-200 bg-surface— appropriately signaling a destructive section without using raw hex values.person_merge_warning+person_merge_will_be_deleted) currently says the source person will be deleted. It does not mention @mention rewrites, alias deletion, or relationship removal. For the senior audience (60+), a merge is irreversible — the warning must enumerate what will be lost, not just that the person record disappears.PersonTypeaheadused as the merge target selector has no visible label in the panel — it passeslabel={m.person_merge_target_label()}as a prop, so whether the label renders depends onPersonTypeahead's internal implementation. Verify that the label is associated with the input (<label for="...">or wrapping pattern) for screen-reader accessibility.text-white bg-red-600) and cancel button (text-ink-2 border-line) are visually distinct and keyboard-focusable. Touch target: thepx-4 py-2buttons are approximately 36px tall — below the 44px WCAG 2.2 minimum for a senior audience. The primary Merge button should usemin-h-[44px].form?.mergeError) are displayed intext-sm text-red-600. Red text on white at this size needs a contrast check —text-red-600(#DC2626) on white is approximately 5.9:1, which passes WCAG AA for normal text. No finding here.mergeTargetIdhidden input usesbind:valuewhich is Svelte 5 compatible in this context. However, ifPersonTypeaheadcallsonchangewith an empty string when cleared,mergeTargetIdbecomes''and thedisabled={!mergeTargetId}guard correctly prevents submission. Verify that clearing the typeahead resetsshowMergeConfirm = false— the current code does reset it (showMergeConfirm = falsein theonchangehandler). Good.Recommendations
min-h-[44px]: changeclass="rounded bg-red-600 px-4 py-2 ..."toclass="rounded bg-red-600 min-h-[44px] px-4 py-2 ...". This is a WCAG 2.2 SC 2.5.8 requirement for the target audience.PersonTypeaheadrenders a proper<label>element associated to its<input>— runaxe-playwrighton the person edit page to confirm no label-missing violation.⚙️ Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
Observations
TranscriptionBlockRepository.findByPersonIdWithMentionsFetchedandsaveAllAndFlushinside the merge transaction. Both are standard JPA operations against the existing PostgreSQL instance — no new queries to tune at the infrastructure layer.UNIQUEconstraint in V57 (uq_tbmp_block_person) is already applied. The application-layer deduplication described in the issue is the correct approach; there is no need for a new migration.@ElementCollection(fetch = FetchType.EAGER)onmentionedPersonsmeans everyTranscriptionBlockload fetches the sidecar rows. For a merge touching many blocks, this will issue N+1 JPA queries unless the existingfindByPersonIdWithMentionsFetched(which usesJOIN FETCH) is used — and it is, per the issue spec. No N+1 concern if the implementation follows the spec.pg_isreadyhealthcheck and Flyway migration test will catch any schema-level regressions in CI. No additional infrastructure gate is needed.@Transactionalboundary holds an open DB connection for the full duration of thesaveAllAndFlush. For the current archive scale this is not a problem, but it is worth noting in the code comment.Recommendations
@SpringBootTestwith Testcontainers will verify this automatically since Flyway runs all migrations in order.mergePersonsnoting the operational scale constraint:// Scale note: linear in blocks-mentioning-source. Acceptable for archive scale (~10k blocks); revisit if block count exceeds 100k.Decision Queue — Consolidated Open Items
The following decisions were raised across the persona reviews. All require a human call before implementation begins.
Theme 1: Scope of alias and relationship transfer
Raised by: Markus (architect), Elicit (requirements)
The issue body's acceptance criteria explicitly requires alias transfer ("All aliases of S are added to T") and relationship transfer ("All family relationships of S are transferred to T"). Comment #5484 explicitly out-scopes both as "pre-existing limitations."
Code confirms: aliases are cascade-deleted via
CascadeType.ALL, orphanRemoval = trueonPerson.nameAliases. Relationships are cascade-deleted viaON DELETE CASCADEin V54. Both are silent deletions — the user gets no indication that this data is lost.Decision needed: Are alias transfer and relationship transfer in-scope for this issue, or explicitly deferred? If deferred, the acceptance criteria in the issue body must be updated to remove those items, and a separate issue should be opened to track the data-loss behavior.
Theme 2: Blocking dependency on PR-A #366
Raised by: Markus (architect), Elicit (requirements)
rewriteBlockText(referenced in the issue's recommended approach) does not exist in the current codebase. It is introduced by PR-A #366 (PersonMentionPropagationListener), which is still open.Decision needed: Should this issue formally block on #366 merging first? Or should this issue include the
rewriteBlockTextimplementation as part of its own scope? Either is acceptable, but the issue should state the choice explicitly.Theme 3: Regex safety in
rewriteBlockTextRaised by: Nora (security)
The block-text rewrite uses a boundary-anchored regex where the pattern is built from a person's display name. If the display name contains regex metacharacters (e.g.,
Auguste (Müller)), the regex engine will misinterpret them. This needsPattern.quote()applied to the display name before constructing the pattern.Decision needed: Confirm that PR-A #366's
rewriteBlockTextimplementation usesPattern.quote(). If it does not, add it as part of this issue. This is a correctness bug, not a hypothetical — the archive contains names with parentheses and hyphens.Theme 4: Integration test scope
Raised by: Sara (QA)
mergePersonscrosses the person and transcription domain boundaries. A@DataJpaTestslice cannot wire both repositories easily; a@SpringBootTestwith Testcontainers is simpler to set up but slower (~15s vs ~2s).Decision needed: Use
@SpringBootTestforPersonMergeIntegrationTest(simpler wiring) or@DataJpaTest(faster, more focused). Given the cross-domain nature of the merge,@SpringBootTestis the recommended choice — confirm before the test is written.Theme 5: Audit logging for merge operations
Raised by: Nora (security)
A merge is irreversible and destructive. Without an audit log entry, there is no record of who merged whom and when. The
AuditServicealready exists in theaudit/package.Decision needed: Should
mergePersonsemit an audit log entry? Recommendation from all personas: yes. Confirm so the implementer can wireAuditServicewithout a separate PR.