From e4d48aaa70a3a05a7274f25a8b8cec6a64ef01b2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 11:38:07 +0200 Subject: [PATCH 01/10] feat(person): enforce person-delete integrity at the DB layer (V71) (#684) Add ON DELETE behaviour to the two V1 FKs into persons (documents.sender_id -> SET NULL, document_receivers.person_id -> CASCADE) and a real FK with ON DELETE CASCADE on the transcription_block_mentioned_persons soft reference, cleaning up pre-existing orphan mention rows first. The cascade stays strictly at the join/reference layer and never reaches documents rows. Proven by new Postgres-backed PersonRepositoryTest cascade tests (AC-1/2/3/8 plus the cascade-boundary document-survival guard). Rewrites the now-stale V56 'no FK' comment. Co-Authored-By: Claude Opus 4.8 --- ..._transcription_block_mentioned_persons.sql | 11 +- .../V71__person_delete_on_delete_fk.sql | 50 +++++++ .../person/PersonRepositoryTest.java | 130 ++++++++++++++++++ 3 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V71__person_delete_on_delete_fk.sql diff --git a/backend/src/main/resources/db/migration/V56__add_transcription_block_mentioned_persons.sql b/backend/src/main/resources/db/migration/V56__add_transcription_block_mentioned_persons.sql index 80a44ef4..5620ced6 100644 --- a/backend/src/main/resources/db/migration/V56__add_transcription_block_mentioned_persons.sql +++ b/backend/src/main/resources/db/migration/V56__add_transcription_block_mentioned_persons.sql @@ -8,12 +8,13 @@ -- all blocks mentioning person X" query on the person detail page joins on -- the indexed person_id column — equally fast as JSONB GIN containment, no -- new dependency. document_comments.comment_mentions stays as a many-to-many --- to AppUser; the divergence is intentional: Person mentions need lazy --- degradation when a person is deleted (no FK), while user mentions don't. +-- to AppUser; the divergence is intentional. -- --- No FK on person_id: when a Person is deleted we want @Auguste Raddatz to --- remain visible as plain unlinked text inside the transcription rather than --- vanishing or cascade-deleting the block. +-- person_id FK: added in V71 with ON DELETE CASCADE (this column was originally +-- created without a FK — that decision was reversed). The literal "@Name" in +-- transcription_blocks.text keeps the name visible after a person is deleted; +-- the sidecar row carries only the link and is removed with the person, so the +-- read renderer degrades to plain unlinked text. See ADR-032. CREATE TABLE transcription_block_mentioned_persons ( block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE, diff --git a/backend/src/main/resources/db/migration/V71__person_delete_on_delete_fk.sql b/backend/src/main/resources/db/migration/V71__person_delete_on_delete_fk.sql new file mode 100644 index 00000000..252c215b --- /dev/null +++ b/backend/src/main/resources/db/migration/V71__person_delete_on_delete_fk.sql @@ -0,0 +1,50 @@ +-- Move person-delete referential integrity from application code into the database (#684). +-- +-- Before this migration, PersonService.deletePerson nulled documents.sender_id and removed +-- document_receivers rows in Java before deleting the person, because the two V1 FKs into +-- persons had no ON DELETE behaviour. Any other delete path (a future endpoint, a manual +-- psql, a batch job) could still orphan rows or 500. This migration makes the database the +-- single source of truth so a person delete is safe from every path. +-- +-- Cascade boundary: the cascade stays STRICTLY at the join/reference layer and NEVER reaches +-- documents rows — a cascade into documents would destroy historical letters. sender_id is +-- SET NULL (documents.senderText preserves the raw textual attribution); the receiver join +-- row and the @-mention sidecar row are dropped. +-- +-- No NOT VALID + VALIDATE two-step: these tables are small (thousands of rows → sub-second +-- ACCESS EXCLUSIVE lock). Do NOT copy this drop-and-recreate pattern onto a large table. +-- +-- Not audit-logged: a DB ON DELETE cascade runs below AuditService — a known, accepted trade. +-- The person-delete action itself is still logged at the service layer. + +-- documents.sender_id → ON DELETE SET NULL (deleted sender clears the link; the document survives). +ALTER TABLE public.documents + DROP CONSTRAINT fkl5xhww7es3b4um01vmly4y18m, + ADD CONSTRAINT fkl5xhww7es3b4um01vmly4y18m + FOREIGN KEY (sender_id) REFERENCES public.persons(id) ON DELETE SET NULL; + +-- document_receivers.person_id → ON DELETE CASCADE (drop the join row), the symmetric +-- completion of V14, which added the same to the document_id side of this table. +ALTER TABLE public.document_receivers + DROP CONSTRAINT fkcg7r68qvosqricx1betgrlt7s, + ADD CONSTRAINT fkcg7r68qvosqricx1betgrlt7s + FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE; + +-- Soft reference fix: transcription_block_mentioned_persons.person_id was a UUID with no FK +-- (V56), so deleting a person left dangling mention rows. Give it a real FK with CASCADE. +-- Clean up pre-existing orphans first — production likely holds dangling rows because the old +-- deletePerson never cleaned mention rows, and the ADD CONSTRAINT validation scan fails on them. +-- A DO block with RAISE NOTICE surfaces the purge count: Flyway runs each statement via JDBC +-- and discards a trailing SELECT's result set, so a "SELECT count(*)" would log nothing. +DO $$ +DECLARE removed int; +BEGIN + DELETE FROM transcription_block_mentioned_persons m + WHERE NOT EXISTS (SELECT 1 FROM persons p WHERE p.id = m.person_id); + GET DIAGNOSTICS removed = ROW_COUNT; + RAISE NOTICE 'V71 orphaned_mention_rows_removed=%', removed; +END $$; + +ALTER TABLE public.transcription_block_mentioned_persons + ADD CONSTRAINT fk_tbmp_person + FOREIGN KEY (person_id) REFERENCES public.persons(id) ON DELETE CASCADE; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index add05ed7..dbccb285 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -21,6 +21,7 @@ import jakarta.persistence.PersistenceContext; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -707,4 +708,133 @@ class PersonRepositoryTest { assertThat(found).isPresent(); assertThat(found.get().getGeneration()).isNull(); } + + // ─── #684: ON DELETE integrity enforced at the database layer ────────────── + // A raw deleteById (bypassing PersonService) must keep referential integrity: + // documents.sender_id → SET NULL, document_receivers.person_id → CASCADE, and the + // transcription_block_mentioned_persons soft reference → CASCADE. These run against + // real Postgres because the FK ON DELETE behaviour never fires on H2. + + @Test + void deleteById_personSenderOfAReceiverOfB_nullsSender_dropsReceiverRow_bothDocumentsSurvive() { + Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build()); + Person bystander = personRepository.save(Person.builder().firstName("Bleibt").lastName("Hier").build()); + + Document sent = documentRepository.save(Document.builder() + .title("Gesendet").originalFilename("sent.pdf") + .status(DocumentStatus.UPLOADED).sender(target).build()); + Document received = documentRepository.save(Document.builder() + .title("Empfangen").originalFilename("received.pdf") + .status(DocumentStatus.UPLOADED).sender(bystander) + .receivers(Set.of(target)).build()); + entityManager.flush(); + entityManager.clear(); + + personRepository.deleteById(target.getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(personRepository.findById(target.getId())).isEmpty(); + + Document reloadedSent = documentRepository.findById(sent.getId()).orElseThrow(); + assertThat(reloadedSent.getSender()).isNull(); // AC-1: SET NULL + + Document reloadedReceived = documentRepository.findById(received.getId()).orElseThrow(); + assertThat(reloadedReceived.getReceivers()) + .noneMatch(p -> p.getId().equals(target.getId())); // AC-2: CASCADE drops the join row + + // Cascade-boundary guard (Nora, non-negotiable): the cascade stops at the join/reference + // layer — both documents themselves survive. Guards against a future migration turning + // documents.sender_id SET NULL into CASCADE and destroying historical letters. + assertThat(documentRepository.findById(sent.getId())).isPresent(); + assertThat(documentRepository.findById(received.getId())).isPresent(); + } + + @Test + void deleteById_receiverWithCoReceiver_dropsOnlyDeletedPersonsJoinRow() { + Person target = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build()); + Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build()); + Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build()); + + Document doc = documentRepository.save(Document.builder() + .title("Brief").originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED).sender(sender) + .receivers(Set.of(target, coReceiver)).build()); + entityManager.flush(); + entityManager.clear(); + + personRepository.deleteById(target.getId()); + entityManager.flush(); + entityManager.clear(); + + Document reloaded = documentRepository.findById(doc.getId()).orElseThrow(); + assertThat(reloaded.getReceivers()).extracting(Person::getId) + .containsExactly(coReceiver.getId()); // co-receiver untouched + } + + @Test + void deleteById_personIsSenderAndReceiverOfSameDocument_documentSurvives_senderNull_receiverDropped() { + // AC-8: the trickier same-document interaction the cross-document cases don't exercise. + Person target = personRepository.save(Person.builder().firstName("Beides").lastName("Person").build()); + Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build()); + + Document doc = documentRepository.save(Document.builder() + .title("Selbstbrief").originalFilename("self.pdf") + .status(DocumentStatus.UPLOADED).sender(target) + .receivers(Set.of(target, coReceiver)).build()); + entityManager.flush(); + entityManager.clear(); + + personRepository.deleteById(target.getId()); + entityManager.flush(); + entityManager.clear(); + + Document reloaded = documentRepository.findById(doc.getId()).orElseThrow(); + assertThat(reloaded.getSender()).isNull(); + assertThat(reloaded.getReceivers()).extracting(Person::getId) + .containsExactly(coReceiver.getId()); + } + + @Test + void deleteById_mentionedPerson_dropsMentionRow_blockTextSurvives() { + // AC-3: the @-mention sidecar is a CASCADE soft reference, but the literal "@Name" lives + // in transcription_blocks.text and must stay visible as plain text after the person goes. + Person mentioned = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()); + Document doc = documentRepository.save(Document.builder() + .title("Brief").originalFilename("brief.pdf") + .status(DocumentStatus.UPLOADED).build()); + entityManager.flush(); + + UUID annotationId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + entityManager.createNativeQuery( + "INSERT INTO document_annotations (id, document_id, page_number, x, y, width, height, color) " + + "VALUES (?1, ?2, 1, 0.1, 0.2, 0.3, 0.1, '#fff')") + .setParameter(1, annotationId).setParameter(2, doc.getId()).executeUpdate(); + entityManager.createNativeQuery( + "INSERT INTO transcription_blocks (id, annotation_id, document_id, text) VALUES (?1, ?2, ?3, ?4)") + .setParameter(1, blockId).setParameter(2, annotationId).setParameter(3, doc.getId()) + .setParameter(4, "Brief an @Auguste Raddatz").executeUpdate(); + entityManager.createNativeQuery( + "INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) " + + "VALUES (?1, ?2, ?3)") + .setParameter(1, blockId).setParameter(2, mentioned.getId()) + .setParameter(3, "Auguste Raddatz").executeUpdate(); + entityManager.flush(); + entityManager.clear(); + + personRepository.deleteById(mentioned.getId()); + entityManager.flush(); + entityManager.clear(); + + Number mentionRows = (Number) entityManager.createNativeQuery( + "SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1") + .setParameter(1, mentioned.getId()).getSingleResult(); + assertThat(mentionRows.longValue()).isZero(); + + String text = (String) entityManager.createNativeQuery( + "SELECT text FROM transcription_blocks WHERE id = ?1") + .setParameter(1, blockId).getSingleResult(); + assertThat(text).isEqualTo("Brief an @Auguste Raddatz"); + } } -- 2.49.1 From b307fe083b27f5a2253737ed676df914474529de Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 11:41:50 +0200 Subject: [PATCH 02/10] refactor(person): thin deletePerson to lean on V71 DB cascade (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the application-layer sender/receiver detach from deletePerson — the V71 ON DELETE constraints now enforce it. Remove the now-unused reassignSenderToNull repository method and rewrite the unit test to assert only the existence check plus deleteById (verifyNoMoreInteractions). Co-Authored-By: Claude Opus 4.8 --- .../raddatz/familienarchiv/person/PersonRepository.java | 6 ------ .../org/raddatz/familienarchiv/person/PersonService.java | 8 +++----- .../raddatz/familienarchiv/person/PersonServiceTest.java | 7 +++++-- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index 50ff4ee9..75b265d0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -194,12 +194,6 @@ public interface PersonRepository extends JpaRepository { @Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true) void reassignSender(@Param("source") UUID source, @Param("target") UUID target); - // Used by deletePerson: detach a deleted person from documents they sent, so the hard - // delete cannot orphan a documents.sender_id FK (the column is nullable). - @Modifying - @Query(value = "UPDATE documents SET sender_id = NULL WHERE sender_id = :source", nativeQuery = true) - void reassignSenderToNull(@Param("source") UUID source); - @Modifying @Query(value = """ INSERT INTO document_receivers (document_id, person_id) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index 2dcdf777..8a8066fe 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -68,15 +68,13 @@ public class PersonService { } /** - * Hard-deletes a person used by triage. Detaches the person from any documents they - * sent (nulls sender_id) and from any received-document references first, so the delete - * cannot orphan an FK and fail with a 500. + * Hard-deletes a person used by triage. Referential integrity is enforced by the database + * (V71's {@code ON DELETE} constraints: sender_id {@code SET NULL}, receiver and @-mention + * rows {@code CASCADE}), so the service stays thin — it only verifies existence then deletes. */ @Transactional public void deletePerson(UUID id) { getById(id); - personRepository.reassignSenderToNull(id); - personRepository.deleteReceiverReferences(id); personRepository.deleteById(id); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java index a42a3051..18e70658 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -147,9 +148,11 @@ class PersonServiceTest { personService.deletePerson(id); - verify(personRepository).reassignSenderToNull(id); - verify(personRepository).deleteReceiverReferences(id); + // Integrity is enforced by V71's ON DELETE constraints — the service only checks + // existence then deletes; it no longer detaches sender/receiver references itself. + verify(personRepository).findById(id); verify(personRepository).deleteById(id); + verifyNoMoreInteractions(personRepository); } @Test -- 2.49.1 From 8e975daf98d496f0a57f972a10b3800c9dd3d799 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 11:46:10 +0200 Subject: [PATCH 03/10] refactor(person): simplify mergePersons to lean on V71 cascade (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the explicit deleteReceiverReferences call from mergePersons — the source's leftover receiver join rows now cascade-drop via V71's ON DELETE CASCADE on deleteById. Remove the now-unused deleteReceiverReferences repository method (and its repo test), and add clearAutomatically + flushAutomatically to the remaining merge native queries so the L1 cache cannot desync from the bulk updates. Rewrite the merge unit test with verifyNoMoreInteractions and add an end-to-end merge regression test (AC-7). Co-Authored-By: Claude Opus 4.8 --- .../person/PersonRepository.java | 11 +++--- .../familienarchiv/person/PersonService.java | 10 ++++-- .../person/PersonRepositoryTest.java | 24 ------------- .../person/PersonServiceIntegrationTest.java | 34 +++++++++++++++++++ .../person/PersonServiceTest.java | 6 +++- 5 files changed, 51 insertions(+), 34 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index 75b265d0..ecd77d54 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -189,12 +189,15 @@ public interface PersonRepository extends JpaRepository { List findCorrespondentsWithFilter(@Param("personId") UUID personId, @Param("q") String q); // --- Merge helpers (native SQL to bypass JPA entity layer) --- + // clearAutomatically + flushAutomatically keep the L1 cache from desyncing: these bulk + // updates run beneath Hibernate, and mergePersons follows them with a deleteById whose + // ON DELETE CASCADE (V71) also fires beneath the session. - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true) void reassignSender(@Param("source") UUID source, @Param("target") UUID target); - @Modifying + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query(value = """ INSERT INTO document_receivers (document_id, person_id) SELECT document_id, :target FROM document_receivers @@ -204,8 +207,4 @@ public interface PersonRepository extends JpaRepository { ) """, nativeQuery = true) void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target); - - @Modifying - @Query(value = "DELETE FROM document_receivers WHERE person_id = :source", nativeQuery = true) - void deleteReceiverReferences(@Param("source") UUID source); } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java index 8a8066fe..1646c236 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonService.java @@ -293,6 +293,12 @@ public class PersonService { return personRepository.save(person); } + /** + * Merges the source person into the target, then deletes the source. Sender references move + * to the target; receiver references the target lacks are inserted. The source's leftover + * receiver join rows are not deleted explicitly — they cascade-drop via V71's + * {@code ON DELETE CASCADE} on {@code document_receivers.person_id} when the source is deleted. + */ @Transactional public void mergePersons(UUID sourceId, UUID targetId) { if (sourceId.equals(targetId)) { @@ -309,9 +315,7 @@ public class PersonService { // Add target as receiver where source is receiver but target is not yet personRepository.insertMissingReceiverReference(sourceId, targetId); - // Remove all remaining source receiver references (duplicates already handled) - personRepository.deleteReceiverReferences(sourceId); - + // Source's remaining receiver rows cascade-drop via V71's ON DELETE CASCADE. personRepository.deleteById(sourceId); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index dbccb285..874f8b3c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -367,30 +367,6 @@ class PersonRepositoryTest { assertThat(result).hasSize(1); } - // ─── deleteReceiverReferences ───────────────────────────────────────────── - - @Test - void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() { - Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build()); - Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build()); - - Document doc1 = documentRepository.save(Document.builder() - .title("Brief 1").originalFilename("b1.pdf") - .status(DocumentStatus.UPLOADED) - .sender(sender).receivers(Set.of(toDelete)).build()); - Document doc2 = documentRepository.save(Document.builder() - .title("Brief 2").originalFilename("b2.pdf") - .status(DocumentStatus.UPLOADED) - .sender(sender).receivers(Set.of(toDelete)).build()); - - personRepository.deleteReceiverReferences(toDelete.getId()); - entityManager.flush(); - entityManager.clear(); - - assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty(); - assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty(); - } - // ─── searchByName with aliases ─────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java index 5b9d0386..dfb1fa57 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java @@ -220,4 +220,38 @@ class PersonServiceIntegrationTest { // The other person and the documents themselves survive the delete. assertThat(personRepository.findById(bystander.getId())).isPresent(); } + + @Test + void mergePersons_targetInheritsReferences_sourceJoinRowCascadeDrops_noFkError() { + // AC-7: merging a source who is sender of A and receiver of B into a target leaves the + // target as sender of A and receiver of B, drops the source's leftover receiver row via + // V71's ON DELETE CASCADE (no explicit delete, no FK error), and co-receivers are intact. + Person source = personRepository.save(Person.builder().firstName("Anna").lastName("Alt").build()); + Person target = personRepository.save(Person.builder().firstName("Anna").lastName("Neu").build()); + Person coReceiver = personRepository.save(Person.builder().firstName("Mit").lastName("Empfänger").build()); + Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build()); + + Document docA = documentRepository.save(Document.builder() + .title("Von Anna").originalFilename("a.pdf") + .status(DocumentStatus.UPLOADED).sender(source).build()); + Document docB = documentRepository.save(Document.builder() + .title("An Anna").originalFilename("b.pdf") + .status(DocumentStatus.UPLOADED).sender(sender) + .receivers(new java.util.HashSet<>(Set.of(source, coReceiver))).build()); + entityManager.flush(); + entityManager.clear(); + + personService.mergePersons(source.getId(), target.getId()); + entityManager.flush(); + entityManager.clear(); + + assertThat(personRepository.findById(source.getId())).isEmpty(); + + Document reloadedA = documentRepository.findById(docA.getId()).orElseThrow(); + assertThat(reloadedA.getSender().getId()).isEqualTo(target.getId()); + + Document reloadedB = documentRepository.findById(docB.getId()).orElseThrow(); + assertThat(reloadedB.getReceivers()).extracting(Person::getId) + .containsExactlyInAnyOrder(target.getId(), coReceiver.getId()); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java index 18e70658..e6a9035a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceTest.java @@ -703,10 +703,14 @@ class PersonServiceTest { personService.mergePersons(sourceId, targetId); + verify(personRepository).findById(sourceId); + verify(personRepository).findById(targetId); verify(personRepository).reassignSender(sourceId, targetId); verify(personRepository).insertMissingReceiverReference(sourceId, targetId); - verify(personRepository).deleteReceiverReferences(sourceId); verify(personRepository).deleteById(sourceId); + // The source's leftover receiver rows cascade-drop via V71's ON DELETE CASCADE on + // deleteById — merge no longer deletes them explicitly. + verifyNoMoreInteractions(personRepository); } // ─── getAliases ───────────────────────────────────────────────────────── -- 2.49.1 From 82e86ed110aa72ba7584e6449ec7a50d5815c2cf Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 11:48:10 +0200 Subject: [PATCH 04/10] test(person): describe DB-cascade mechanism in delete service-path test (#684) The deletePerson service-path guard (AC-4) is unchanged behaviourally, but its comments described the removed reassignSenderToNull/deleteReceiverReferences chain. Update them to the V71 ON DELETE cascade mechanism. Co-Authored-By: Claude Opus 4.8 --- .../person/PersonServiceIntegrationTest.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java index dfb1fa57..d26883cd 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java @@ -180,9 +180,9 @@ class PersonServiceIntegrationTest { @Test void deletePerson_detachesSentAndReceivedReferences_beforeDelete_noOrphan() { // A person referenced as BOTH a document sender and a document receiver must delete - // cleanly: deletePerson nulls the sender_id FK and removes the receiver join row first - // (reassignSenderToNull → deleteReceiverReferences → deleteById), so no FK orphan and - // the documents themselves survive. + // cleanly via the service path: deletePerson just calls deleteById, and V71's ON DELETE + // constraints null the sender_id FK and drop the receiver join row, so there is no FK + // orphan and the documents themselves survive. Person target = personRepository.save(Person.builder() .firstName("Weg").lastName("Person").provisional(true).build()); Person bystander = personRepository.save(Person.builder() @@ -196,16 +196,16 @@ class PersonServiceIntegrationTest { .status(DocumentStatus.UPLOADED).sender(bystander) .receivers(new java.util.HashSet<>(Set.of(target))).build()); - // Persist the fixture and detach everything so the native @Modifying deletes operate on - // the database directly without the persistence context holding stale references that - // would re-flush a now-deleted person as a transient association. + // Persist the fixture and detach everything so the delete operates on the database + // directly without the persistence context holding stale references. entityManager.flush(); entityManager.clear(); personService.deletePerson(target.getId()); - // Native @Modifying queries bypass the persistence context — clear it so the asserting - // reads observe the post-delete database state, not stale managed entities. + // The ON DELETE cascade fires beneath Hibernate — flush the delete and clear the L1 + // cache so the asserting reads observe the post-delete database state, not stale + // managed entities (the EAGER @ElementCollection on receivers makes this load-bearing). entityManager.flush(); entityManager.clear(); -- 2.49.1 From 149d23a5e60b2847740bb0ed20ccc8d85062620e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 11:49:48 +0200 Subject: [PATCH 05/10] test(mention): lock deleted-person graceful-degradation contract (#684) Strengthen one renderTranscriptionBody case into the AC-6 contract: a @DisplayName with an empty mentionedPersons array (the deleted-person case V71 produces) must render as plain readable text with no , person-mention class, data-person-id, or href. Guards against a future renderer refactor silently reintroducing the dead-link-on-deleted-person degradation. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/shared/discussion/mention.spec.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/shared/discussion/mention.spec.ts b/frontend/src/lib/shared/discussion/mention.spec.ts index f063ab3b..a049d669 100644 --- a/frontend/src/lib/shared/discussion/mention.spec.ts +++ b/frontend/src/lib/shared/discussion/mention.spec.ts @@ -307,9 +307,21 @@ describe('renderTranscriptionBody', () => { expect(result).not.toMatch(/>O"Brien<\/a>/); }); - it('renders nothing when mentionedPersons is undefined-empty and no @ triggers', () => { - const result = renderTranscriptionBody('Plain old transcription text.', []); - expect(result).toBe('Plain old transcription text.'); + it('renders a deleted-person @mention as plain text with no dead link (graceful degradation)', () => { + // AC-6 (#684): when a mentioned person is deleted, V71's ON DELETE CASCADE removes the + // sidecar row, so the displayName reaches the renderer with an empty mentionedPersons + // array. The reader must still see the name as plain text — never a dead , a + // person-mention class, a data-person-id, or an href. This locks the degradation + // contract so a future renderer refactor cannot silently reintroduce a dead link. + const result = renderTranscriptionBody('Brief an @Auguste Raddatz vom Mai', []); + // (a) the reader still sees the readable name and the surrounding sentence verbatim + expect(result).toContain('Auguste Raddatz'); + expect(result).toBe('Brief an @Auguste Raddatz vom Mai'); + // (b) none of the anchor artifacts leak through + expect(result).not.toContain(' { -- 2.49.1 From 131cdb6a2b3ee467ed8dbaaafcc4eaa0869d5507 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 11:51:29 +0200 Subject: [PATCH 06/10] docs(db): annotate person-delete ON DELETE behaviour in DB diagrams (#684) Annotate SET NULL on documents.sender_id and CASCADE on document_receivers.person_id, and add the new transcription_block_mentioned_persons -> persons person_id FK (CASCADE) to both db-relationships.puml and db-orm.puml. Co-Authored-By: Claude Opus 4.8 --- docs/architecture/db/db-orm.puml | 7 ++++--- docs/architecture/db/db-relationships.puml | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/architecture/db/db-orm.puml b/docs/architecture/db/db-orm.puml index cb37a60f..48578048 100644 --- a/docs/architecture/db/db-orm.puml +++ b/docs/architecture/db/db-orm.puml @@ -260,7 +260,7 @@ package "Transcription" { entity transcription_block_mentioned_persons { block_id : UUID <> - person_id : UUID NOT NULL + person_id : UUID NOT NULL <> -- display_name : VARCHAR(200) NOT NULL } @@ -386,9 +386,9 @@ invite_token_group_ids }o--|| invite_tokens : invite_token_id invite_token_group_ids }o--|| user_groups : group_id ' Document relationships -documents }o--o| persons : sender_id +documents }o--o| persons : sender_id (ON DELETE SET NULL) document_receivers }o--|| documents : document_id -document_receivers }o--|| persons : person_id +document_receivers }o--|| persons : person_id (ON DELETE CASCADE) document_tags }o--|| documents : document_id document_tags }o--|| tag : tag_id document_versions }o--|| documents : document_id @@ -420,6 +420,7 @@ transcription_blocks }o--o| app_users : updated_by transcription_block_versions }o--|| transcription_blocks : block_id transcription_block_versions }o--o| app_users : changed_by transcription_block_mentioned_persons }o--|| transcription_blocks : block_id +transcription_block_mentioned_persons }o--|| persons : person_id (ON DELETE CASCADE) ' OCR relationships ocr_job_documents }o--|| ocr_jobs : job_id diff --git a/docs/architecture/db/db-relationships.puml b/docs/architecture/db/db-relationships.puml index d6f4b542..82056761 100644 --- a/docs/architecture/db/db-relationships.puml +++ b/docs/architecture/db/db-relationships.puml @@ -79,9 +79,9 @@ invite_token_group_ids }o--|| invite_tokens : invite_token_id invite_token_group_ids }o--|| user_groups : group_id ' Document relationships -documents }o--o| persons : sender_id +documents }o--o| persons : sender_id (ON DELETE SET NULL) document_receivers }o--|| documents : document_id -document_receivers }o--|| persons : person_id +document_receivers }o--|| persons : person_id (ON DELETE CASCADE) document_tags }o--|| documents : document_id document_tags }o--|| tag : tag_id document_versions }o--|| documents : document_id @@ -113,6 +113,7 @@ transcription_blocks }o--o| app_users : updated_by transcription_block_versions }o--|| transcription_blocks : block_id transcription_block_versions }o--o| app_users : changed_by transcription_block_mentioned_persons }o--|| transcription_blocks : block_id +transcription_block_mentioned_persons }o--|| persons : person_id (ON DELETE CASCADE) ' OCR relationships ocr_job_documents }o--|| ocr_jobs : job_id -- 2.49.1 From 24af76662b4f532196dc915f7ffddcb618db50ec Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 11:52:39 +0200 Subject: [PATCH 07/10] docs(adr): record DB-level person-delete integrity decision (ADR-032) (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the reversal of V56's no-FK decision, the DB-layer-integrity principle, and the cascade-boundary invariant (the cascade never reaches documents rows). Numbered 032 — 028-031 are already taken on main; the issue's '028 is next' was written before main moved. Co-Authored-By: Claude Opus 4.8 --- .../032-person-delete-db-level-integrity.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/adr/032-person-delete-db-level-integrity.md diff --git a/docs/adr/032-person-delete-db-level-integrity.md b/docs/adr/032-person-delete-db-level-integrity.md new file mode 100644 index 00000000..de44a50c --- /dev/null +++ b/docs/adr/032-person-delete-db-level-integrity.md @@ -0,0 +1,62 @@ +# ADR-032 — Person-delete referential integrity lives in the database, and the cascade never reaches `documents` + +**Date:** 2026-06-06 +**Status:** Accepted +**Issue:** #684 (move person-delete FK detach to database-level `ON DELETE`) +**Milestone:** — + +--- + +## Context + +Deleting a `Person` had to detach the two FKs into `persons` that lacked any `ON DELETE` +behaviour: `documents.sender_id` and `document_receivers.person_id` (both from V1). +`PersonService.deletePerson` and `mergePersons` did this in Java — nulling the sender and +deleting receiver join rows before `deleteById` — so the integrity guarantee lived in +application code. Any other delete path (a future endpoint, a manual `psql`, a batch job) +could still orphan rows or fail with an FK-violation 500. + +A related soft reference made it worse: `transcription_block_mentioned_persons.person_id` +was a UUID column with **no FK** (V56, a deliberate "no FK" choice), so a person delete left +dangling `@`-mention rows. The literal `@DisplayName` lives in `transcription_blocks.text`, +so only the *link* was ever at stake — not the visible name. + +## Decision + +Move person-delete integrity into the database (migration V71) and thin the service to a +plain `deleteById`: + +- `documents.sender_id` → `ON DELETE SET NULL` (`documents.senderText` preserves the raw + textual attribution, so nulling the link loses no historical record). +- `document_receivers.person_id` → `ON DELETE CASCADE` (the symmetric completion of V14, + which gave the `document_id` side the same). +- `transcription_block_mentioned_persons.person_id` → a real FK with `ON DELETE CASCADE`, + reversing V56's "no FK" decision. The read renderer already degrades a `@DisplayName` with + no sidecar row to plain escaped text, so removing the link is invisible to the reader. + +**Cascade-boundary invariant:** the cascade stays strictly at the join/reference layer and +**never reaches `documents` rows** — a cascade into `documents` would destroy historical +letters. This is pinned by a non-negotiable document-survival assertion in +`PersonRepositoryTest`. + +## Consequences + +- A person delete is safe from every path, not just `PersonService`. The service and merge + stay thin (`deleteById` + the cascade); `reassignSenderToNull` and `deleteReceiverReferences` + are deleted. +- This *fixes* the pre-existing dead-link-on-deleted-person case — it is not a purely + invisible refactor. +- DB cascades run below `AuditService`, so the row-level cleanup is intentionally not + audit-logged; the person-delete action itself is still logged at the service layer. +- The V71 FK validation requires cleaning pre-existing orphan mention rows first; the + migration does this in a `DO` block that logs the purge count via `RAISE NOTICE`. + +## Alternatives considered + +- **Keep integrity in Java** — rejected; it only protects the one code path and re-breaks the + moment a second delete path appears. +- **Cascade `documents.sender_id`** — rejected; it would delete historical letters when a + sender is removed. `SET NULL` keeps the letter and its `senderText`. +- **Leave the mention sidecar FK-less (honour V56)** — rejected; the "no FK" rationale was + stale, the name survives in the block text regardless, and the FK removes the orphan-row + class of bug. -- 2.49.1 From 135082c5c0e66f086b2f8c44c211798d64df17f9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 12:14:30 +0200 Subject: [PATCH 08/10] fix(db): leave V56 untouched to avoid Flyway checksum drift (#684) Editing an already-applied migration changes its Flyway checksum and would fail validateOnMigrate against prod (where V56 is applied). Revert the V56 comment edit; V71 now records that it reverses V56's no-FK choice and points to ADR-032 as the authoritative record, so the V56 -> V71 trail stays discoverable without touching the applied migration. (DevOps review, PR #736.) Co-Authored-By: Claude Opus 4.8 --- ...V56__add_transcription_block_mentioned_persons.sql | 11 +++++------ .../db/migration/V71__person_delete_on_delete_fk.sql | 3 +++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/src/main/resources/db/migration/V56__add_transcription_block_mentioned_persons.sql b/backend/src/main/resources/db/migration/V56__add_transcription_block_mentioned_persons.sql index 5620ced6..80a44ef4 100644 --- a/backend/src/main/resources/db/migration/V56__add_transcription_block_mentioned_persons.sql +++ b/backend/src/main/resources/db/migration/V56__add_transcription_block_mentioned_persons.sql @@ -8,13 +8,12 @@ -- all blocks mentioning person X" query on the person detail page joins on -- the indexed person_id column — equally fast as JSONB GIN containment, no -- new dependency. document_comments.comment_mentions stays as a many-to-many --- to AppUser; the divergence is intentional. +-- to AppUser; the divergence is intentional: Person mentions need lazy +-- degradation when a person is deleted (no FK), while user mentions don't. -- --- person_id FK: added in V71 with ON DELETE CASCADE (this column was originally --- created without a FK — that decision was reversed). The literal "@Name" in --- transcription_blocks.text keeps the name visible after a person is deleted; --- the sidecar row carries only the link and is removed with the person, so the --- read renderer degrades to plain unlinked text. See ADR-032. +-- No FK on person_id: when a Person is deleted we want @Auguste Raddatz to +-- remain visible as plain unlinked text inside the transcription rather than +-- vanishing or cascade-deleting the block. CREATE TABLE transcription_block_mentioned_persons ( block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE, diff --git a/backend/src/main/resources/db/migration/V71__person_delete_on_delete_fk.sql b/backend/src/main/resources/db/migration/V71__person_delete_on_delete_fk.sql index 252c215b..efcadad5 100644 --- a/backend/src/main/resources/db/migration/V71__person_delete_on_delete_fk.sql +++ b/backend/src/main/resources/db/migration/V71__person_delete_on_delete_fk.sql @@ -32,6 +32,9 @@ ALTER TABLE public.document_receivers -- Soft reference fix: transcription_block_mentioned_persons.person_id was a UUID with no FK -- (V56), so deleting a person left dangling mention rows. Give it a real FK with CASCADE. +-- This reverses V56's deliberate "no FK on person_id" choice — that comment is now historical +-- but is intentionally left untouched, because editing an already-applied migration changes its +-- Flyway checksum and would fail validateOnMigrate in prod. ADR-032 is the authoritative record. -- Clean up pre-existing orphans first — production likely holds dangling rows because the old -- deletePerson never cleaned mention rows, and the ADD CONSTRAINT validation scan fails on them. -- A DO block with RAISE NOTICE surfaces the purge count: Flyway runs each statement via JDBC -- 2.49.1 From 4c2d7ba128993afbb754a146bb4d05e56ec7da10 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 12:14:54 +0200 Subject: [PATCH 09/10] test(person): address PR #736 review nits - AC-3 cascade test: assert an innocent bystander's mention row survives the delete, proving the cascade is scoped to the deleted person (Nora). - Fix integration-test comment: receivers is @ManyToMany(LAZY), not an EAGER @ElementCollection (Sara). - ADR-032: note the @ prefix is kept in the degraded path, stripped in live mentions (Leonie). - Add trailing newline to PersonRepository.java (Felix). Co-Authored-By: Claude Opus 4.8 --- .../familienarchiv/person/PersonRepository.java | 2 +- .../person/PersonRepositoryTest.java | 17 +++++++++++++++-- .../person/PersonServiceIntegrationTest.java | 2 +- .../adr/032-person-delete-db-level-integrity.md | 4 +++- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java index ecd77d54..0ca82751 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/person/PersonRepository.java @@ -207,4 +207,4 @@ public interface PersonRepository extends JpaRepository { ) """, nativeQuery = true) void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target); -} \ No newline at end of file +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java index 874f8b3c..21c24847 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonRepositoryTest.java @@ -776,6 +776,7 @@ class PersonRepositoryTest { // AC-3: the @-mention sidecar is a CASCADE soft reference, but the literal "@Name" lives // in transcription_blocks.text and must stay visible as plain text after the person goes. Person mentioned = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()); + Person survivor = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build()); Document doc = documentRepository.save(Document.builder() .title("Brief").originalFilename("brief.pdf") .status(DocumentStatus.UPLOADED).build()); @@ -790,12 +791,18 @@ class PersonRepositoryTest { entityManager.createNativeQuery( "INSERT INTO transcription_blocks (id, annotation_id, document_id, text) VALUES (?1, ?2, ?3, ?4)") .setParameter(1, blockId).setParameter(2, annotationId).setParameter(3, doc.getId()) - .setParameter(4, "Brief an @Auguste Raddatz").executeUpdate(); + .setParameter(4, "Brief an @Auguste Raddatz und @Clara Cram").executeUpdate(); + // Two mention rows on the same block: the deleted person and an innocent bystander. entityManager.createNativeQuery( "INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) " + "VALUES (?1, ?2, ?3)") .setParameter(1, blockId).setParameter(2, mentioned.getId()) .setParameter(3, "Auguste Raddatz").executeUpdate(); + entityManager.createNativeQuery( + "INSERT INTO transcription_block_mentioned_persons (block_id, person_id, display_name) " + + "VALUES (?1, ?2, ?3)") + .setParameter(1, blockId).setParameter(2, survivor.getId()) + .setParameter(3, "Clara Cram").executeUpdate(); entityManager.flush(); entityManager.clear(); @@ -808,9 +815,15 @@ class PersonRepositoryTest { .setParameter(1, mentioned.getId()).getSingleResult(); assertThat(mentionRows.longValue()).isZero(); + // The cascade is scoped to the deleted person — the bystander's mention row is untouched. + Number survivorRows = (Number) entityManager.createNativeQuery( + "SELECT count(*) FROM transcription_block_mentioned_persons WHERE person_id = ?1") + .setParameter(1, survivor.getId()).getSingleResult(); + assertThat(survivorRows.longValue()).isEqualTo(1); + String text = (String) entityManager.createNativeQuery( "SELECT text FROM transcription_blocks WHERE id = ?1") .setParameter(1, blockId).getSingleResult(); - assertThat(text).isEqualTo("Brief an @Auguste Raddatz"); + assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram"); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java index d26883cd..66e0a93b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/person/PersonServiceIntegrationTest.java @@ -205,7 +205,7 @@ class PersonServiceIntegrationTest { // The ON DELETE cascade fires beneath Hibernate — flush the delete and clear the L1 // cache so the asserting reads observe the post-delete database state, not stale - // managed entities (the EAGER @ElementCollection on receivers makes this load-bearing). + // managed entities still holding the dropped sender/receiver associations. entityManager.flush(); entityManager.clear(); diff --git a/docs/adr/032-person-delete-db-level-integrity.md b/docs/adr/032-person-delete-db-level-integrity.md index de44a50c..76902841 100644 --- a/docs/adr/032-person-delete-db-level-integrity.md +++ b/docs/adr/032-person-delete-db-level-integrity.md @@ -45,7 +45,9 @@ letters. This is pinned by a non-negotiable document-survival assertion in stay thin (`deleteById` + the cascade); `reassignSenderToNull` and `deleteReceiverReferences` are deleted. - This *fixes* the pre-existing dead-link-on-deleted-person case — it is not a purely - invisible refactor. + invisible refactor. Note the read renderer strips the `@` prefix when it emits a live + mention link, but the degraded (deleted-person) path leaves the literal `@Name` in the + block text as-is — the reader sees `@Auguste Raddatz` as plain text, never a dead link. - DB cascades run below `AuditService`, so the row-level cleanup is intentionally not audit-logged; the person-delete action itself is still logged at the service layer. - The V71 FK validation requires cleaning pre-existing orphan mention rows first; the -- 2.49.1 From a5cf5c1054ce244c1440b0557dbf34dca29efcea Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 12:26:15 +0200 Subject: [PATCH 10/10] test(transcription): persist real persons for mention FK after V71 (#684) V71 gives transcription_block_mentioned_persons.person_id a real FK, so two TranscriptionBlockMentionsRepositoryTest cases that inserted mention rows with random (non-existent) person ids now violate fk_tbmp_person. Persist real Person rows and use their ids. Caught by CI's full suite. Co-Authored-By: Claude Opus 4.8 --- .../TranscriptionBlockMentionsRepositoryTest.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockMentionsRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockMentionsRepositoryTest.java index 5742d654..aca4d732 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockMentionsRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/transcription/TranscriptionBlockMentionsRepositoryTest.java @@ -12,6 +12,8 @@ import org.raddatz.familienarchiv.document.annotation.DocumentAnnotation; import org.raddatz.familienarchiv.document.DocumentStatus; import org.raddatz.familienarchiv.document.transcription.PersonMention; import org.raddatz.familienarchiv.document.transcription.TranscriptionBlock; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; @@ -30,6 +32,7 @@ class TranscriptionBlockMentionsRepositoryTest { @Autowired TranscriptionBlockRepository blockRepository; @Autowired DocumentRepository documentRepository; @Autowired AnnotationRepository annotationRepository; + @Autowired PersonRepository personRepository; @Autowired EntityManager em; private UUID documentId; @@ -55,8 +58,9 @@ class TranscriptionBlockMentionsRepositoryTest { @Test void mentionedPersons_roundTripsTwoEntries() { - UUID auguste = UUID.randomUUID(); - UUID hermann = UUID.randomUUID(); + // person_id is a real FK since V71 — the mentioned persons must exist. + UUID auguste = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId(); + UUID hermann = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId(); TranscriptionBlock saved = blockRepository.saveAndFlush(TranscriptionBlock.builder() .annotationId(annotationId) @@ -97,8 +101,9 @@ class TranscriptionBlockMentionsRepositoryTest { @Test void findByPersonIdWithMentionsFetched_returnsOnlyBlocksReferencingPerson_withMentionsLoaded() { - UUID augusteId = UUID.randomUUID(); - UUID hermannId = UUID.randomUUID(); + // person_id is a real FK since V71 — the mentioned persons must exist. + UUID augusteId = personRepository.save(Person.builder().firstName("Auguste").lastName("Raddatz").build()).getId(); + UUID hermannId = personRepository.save(Person.builder().firstName("Hermann").lastName("Müller").build()).getId(); blockRepository.saveAndFlush(TranscriptionBlock.builder() .annotationId(annotationId).documentId(documentId) -- 2.49.1