bug: deleting a document linked via a note-less journey_item 500s at DB constraint #805
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?
Summary
Deleting a
Documentthat is linked via aJourneyItemwithdocument_id IS NOT NULLandnote IS NULLresults in a database-level 500 (ERROR: new row for relation "journey_items" violates check constraint "chk_journey_item_not_empty").Root cause
Two constraints in V72 encode contradictory rules for note-less document items:
FK journey_items.document_id → documents.id ON DELETE SET NULL— when the linked document is deleted, Postgres nulls outdocument_id.chk_journey_item_not_emptyCHECK constraint — requires at least one ofdocument_idornoteto be non-null.A note-less item (
document_idset,noteIS NULL) satisfieschk_journey_item_not_emptywhile the document exists. When the document is deleted, Postgres tries toSET NULLthe FK, which would leave both columns null — a direct violation of the CHECK. Postgres aborts the transaction with a constraint error. This fires at flush/commit, after the controller returns, soGlobalExceptionHandlerdoes not map it to a clean 400 like a normalDataIntegrityViolationException(handleDataIntegrityViolationlogs only the constraint name at WARN, CWE-209-safe) — it falls through tohandleGeneric→Sentry.captureException→ HTTP 500. So every occurrence today is also a real Sentry/GlitchTip event, not just a bad status code.Scope
This was pre-existing before #795 (PR #804), but that PR widens the blast radius: until now only JOURNEY curators could create note-less items; after #795, every STORY editor using
StoryDocumentPanelcreates note-less items by default (adding a document without a curator note). The existing integration testJourneyItemIntegrationTest.deleting_document_sets_item_document_to_null_not_delete_item(line 205) already documents the note-carrying workaround: its item carries a note precisely to avoid triggering this constraint.Reproduction
StoryDocumentPanelorJourneyEditor).journey_itemsFK set-null trigger.✅ Resolved approach (converged after three rounds of multi-persona review)
Decision (UX): when a document is deleted, note-less journey items referencing it silently vanish (the row is deleted). No warning dialog, no RESTRICT, no curator notification. Note-carrying items keep their existing placeholder behavior.
Decision (audit): no per-item audit. The existing document-delete audit entry is the sole record. The event-driven cleanup deliberately bypasses
JourneyItemService.delete(which would emitAuditKind.JOURNEY_ITEM_REMOVED,JourneyItemService.java:158); the listener calls the repository directly and writes no journey-item audit rows. Rationale: a multi-journey document delete would otherwise produce N audit rows for a single user action; the document-delete entry is sufficient provenance.Mechanism — event-driven, NOT a direct service call.
JourneyItemServicealready injectsDocumentService(JourneyItemService.java:9,39); makingDocumentServicecallJourneyItemServiceback creates a constructor-injection cycle, which Spring Boot 4 / Spring Framework 7 prohibits (the app won't boot). Instead:DocumentService.deleteDocumentpublishes a domain event (DocumentDeletingEvent, payload =documentIdUUID only, no entity references across the boundary) viaApplicationEventPublisher(one-line@RequiredArgsConstructorinjection) beforedocumentRepository.deleteById(id)onDocumentService.java:1104, inside the same@Transactionalboundary.@ComponentJourneyItemDocumentDeleteListenerin thegeschichte.journeyitempackage carries a synchronous@EventListenerthat calls the repository delete directly (noJourneyItemServicemethod — routing through the service would re-introduce the audit emission the audit decision explicitly bypasses). Keep it a thin delegator. Use a plain@EventListener— it runs in the publisher's thread and transaction. Do NOT use@Asyncor@TransactionalEventListener(AFTER_COMMIT): AFTER_COMMIT fires after the FK SET NULL has already 500'd, and async runs outside the delete transaction (breaks AC-5). The listener-phase choice is load-bearing — comment it.deleteByIdproceeds; the FKON DELETE SET NULLthen nulls the remaining note-carrying rows (preserving the placeholder UX).Do NOT relax
chk_journey_item_not_emptyand do NOT add a DB trigger — the CHECK is a real invariant and trigger logic is invisible to Java devs. No schema change → no new migration and nodb-orm.puml/db-relationships.pumlupdate. The only doc deliverable is the ADR.Repository — explicit
@Modifying @QueryJPQL (a deriveddeleteByDocumentIdAndNoteIsNullbreaks likeexistsByGeschichteIdAndDocumentIddid, because the transientgetDocumentId()getter (JourneyItem.java:48) makes Spring Data resolvedocumentIdas an unmappable attribute — trap documented atJourneyItemRepository.java:33-44):Use
i.document.id(the real association path), neveri.documentId. Returnint(rows deleted) — useful for a WARN log in the listener and asserted in the idempotency test.clearAutomatically = trueis required so AC-2's "note-carrying survives" assertion doesn't read a stale first-level-cache entity.Acceptance criteria
document_id = NULLand its note unchanged (existing placeholder UX preserved).note = ''— satisfies the CHECK because NOT NULL, but dodges the service-layernormalizeNote) must not re-trigger the 500; it is cascaded. A whitespace-only note (note = ' ') is note-carrying and is preserved (the predicate is= '', notTRIM(note) = ''— pin this boundary so nobody widens it).Tests (start red, real Postgres / Testcontainers only — the constraint never fires under H2)
DocumentService.deleteDocument(id), NOTdocumentRepository.deleteById(id). The existing journey integration tests (JourneyItemIntegrationTest.java:216) delete via the repository, bypassing the service where the event is published — copying that pattern makes the listener never fire and the test pass for the wrong reason (the single biggest green-but-wrong risk here). AutowireDocumentService.deleting_document_sets_item_document_to_null_not_delete_itemto route throughDocumentService(or add a sibling that does) — the repo-path version stays green even if the service path breaks the placeholder behavior.deleting_document_linked_via_note_less_item_deletes_item_not_500— headline (AC-1); confirm it fails with the constraint error before writing the listener.note = ''via raw JDBC /JdbcTemplate(the service can't produce it) → assert cascaded (no 500); insertnote = ' 'via raw JDBC → assert preserved.@SpyBean/@MockBeantheDocumentRepository, let the real listener run on real Postgres, then stubdeleteByIdto throw aRuntimeException(Spring@Transactionalrolls back on RuntimeException by default) after the event fires; re-query from Postgres afterem.clear()and assert the note-less items are still present (guards against stale L1 cache hiding a non-rollback).0fromdeleteNoteLessByDocumentId, does not error, and leaves all unrelated journey-item counts unchanged (guards an over-broad JPQLDELETEmissing thedocument.idpredicate — assert the count, not just absence of an exception).Sequencing & post-deploy
This P1 is a hard predecessor of #795 — merge #805 first, otherwise the first document delete after #795 ships hits the 500. Enforce the merge order in the PR description and milestone. Post-deploy verification:
INTERNAL_ERRORevents on the document-delete path, and/orcount_over_time({...} |= "chk_journey_item_not_empty" [7d])in Loki.Hand-off to #795 (the deleted-document placeholder renderer ships there, not here)
#805 ships no UI. Seed these as real acceptance criteria in #795 before #805 closes (otherwise they disappear with this issue):
{note}(auto-escaped), never{@html}—JourneyItem.noteis stored verbatim (CWE-79 tripwire,JourneyItem.java:41). Verify with a test using a<script>-bearing note. (Bonus:{note}also renders literal&/</>in real curator prose correctly.)aria-labelon the remove control, and a ≥44px touch target on that control.Notes
document_id IS NULLitems rendered as italic removable rows) depends onON DELETE SET NULLworking for items that do have a note. The fix preserves this for note-carrying items.Implementation complete — PR #806
What was implemented
Root fix (
44869d64):DocumentDeletingEvent(UUID documentId)record — first custom domain event in the codebaseJourneyItemDocumentDeleteListener— plain@EventListener(load-bearing choice, see ADR-038) callsjourneyItemRepository.deleteNoteLessByDocumentIddirectly, avoiding the Spring Framework 7 constructor-injection cycle prohibitionJourneyItemRepository.deleteNoteLessByDocumentId— explicit@Modifying @QueryJPQL withi.document.idpath (avoids the transient-getter trap), predicate(note IS NULL OR note = ''),clearAutomatically = trueDocumentService.deleteDocument— publishes event beforedeleteById, addsDOCUMENT_DELETEDaudit entryAuditKind.DOCUMENT_DELETED— new kind; gives AC-7 its positive assertion anchorJourneyItemIntegrationTest— 2 existing tests updated to route throughDocumentService(were bypassing service viadocumentRepository.deleteById)ADR (
eefc67bd):docs/adr/038-domain-event-driven-journey-item-cleanup.mdTests (all Testcontainers/real Postgres, all delete via
DocumentService)deleting_document_linked_via_note_less_item_deletes_item_not_500deleting_document_preserves_note_carrying_item_as_placeholderdeleting_document_does_not_affect_note_only_itemdeleting_document_applies_independently_per_referencing_itemlistener_deletes_roll_back_when_document_delete_failsempty_string_note_item_is_cascaded_whitespace_only_note_is_preserveddeleting_document_in_zero_journeys_returns_no_collateraldeleting_document_emits_document_audit_but_no_journey_item_auditAll 23 affected tests pass. Build clean.