fix(geschichte): delete note-less journey items before document delete (#805) #806
Reference in New Issue
Block a user
Delete Branch "feat/issue-805-journey-note-less-cascade"
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
DocumentService.deleteDocumentnow publishes aDocumentDeletingEventbeforedeleteById;JourneyItemDocumentDeleteListenerhandles it synchronously and deletes all note-less items (note IS NULL OR note = '') for that document, soON DELETE SET NULLonly fires on note-carrying rows that satisfychk_journey_item_not_empty@EventListeneris load-bearing (notAFTER_COMMIT, not@Async) — see ADR-038 for rationaleDOCUMENT_DELETEDtoAuditKind; documents this as the first custom domain event in the codebase⚠️ Merge order
This PR must merge before #795 (PR #804). Merging #795 first means the first document delete after deploy hits the 500.
Test plan
JourneyItemDocumentDeleteTest— 8 new integration tests (real Postgres/Testcontainers) covering AC-1 through AC-7 + idempotency; all passJourneyItemIntegrationTest— 15 existing tests still pass; 2 updated to route deletes throughDocumentServiceJourneyItemServiceTest+JourneyItemConstraintsTest— 52 tests pass./mvnw clean package -DskipTests— build cleanchk_journey_item_not_emptyerrors in GlitchTip / Loki🤖 Generated with Claude Code
🏛️ Markus Keller — Application Architect
Verdict: ⚠️ Approved with concerns
This is how a cross-domain fix should be done. The event direction (
document → journey, publisher knows nothing about consumers) is exactly right, and ADR-038 is one of the better ADRs indocs/adr/— it documents the load-bearing listener-phase choice, names the rejected alternatives with concrete reasons, and explicitly states the boundary rule ("documents must not depend on journey"). The decision to bypassJourneyItemServiceand call the repository directly is justified here: the listener lives insidegeschichte.journeyitem, so it accesses its own domain's repository — no boundary is crossed. The audit-suppression rationale (N journeys would produce N audit rows for one user action) is sound.Pushing the cleanup into the application layer instead of a DB trigger is the right call for this team — trigger logic invisible to code review is a maintenance trap, and the ADR says so.
Blockers
docs/architecture/c4/l3-backend-3g-supporting.pumldoes not reflect the new component. The diagram currently shows onlyGeschichteController/GeschichteService— thejourneyitemsub-domain and nowJourneyItemDocumentDeleteListener+ theDocumentDeletingEventflow are absent. Per our review table, a new component in an existing backend domain requires the matching l3 diagram update. Since this PR targets the #795 feature branch, I accept resolving this on the feature branch before PR #804 merges to main — but it must not reach main undrawn. This is the first event-driven edge in the architecture; it is precisely the kind of invisible coupling the diagrams exist to make visible. A dashedRel(documentSvc, journeyListener, "DocumentDeletingEvent", "in-process event")edge is enough.Suggestions
Consider
@Modifying(clearAutomatically = true, flushAutomatically = true)ondeleteNoteLessByDocumentId(JourneyItemRepository.java:56). Hibernate's AUTO flush mode will flush pending changes whose query space overlapsjourney_itemsbefore the bulk delete, so this is not a bug today — butflushAutomatically = truemakes the contract explicit instead of relying on flush-mode behavior, and it costs nothing. You already did the equivalent favor for readers withclearAutomatically.ADR-038's "first custom domain event" consequence is worth a sentence in
CLAUDE.mdeventually — the next developer who hits a Spring 7 constructor cycle should find the pattern without archaeology. Not required for this PR; the ADR is discoverable.The merge-order constraint (this PR before #804) is structurally enforced by targeting
feat/issue-795-story-documents— good. No schema change, no migration, no new infrastructure. Fix the diagram and this is clean.👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
TDD evidence is strong: eight AC-mapped integration tests with names that read as sentences (
deleting_document_preserves_note_carrying_item_as_placeholder), the rollback guard via@MockitoSpyBean+doThrowis a clean way to prove AC-5, and using rawJdbcTemplateinserts for thenote = ''state the service layer can never produce (AC-6) is exactly the right technique. The repository javadoc explaining why explicit JPQL is needed (the transientgetDocumentId()derived-query trap) is a "why" comment done right.Blockers
DocumentService.java:83:No other field in this class is declared this way. Add the import:
AuditKindjavadoc contradicts the call site —AuditKind.java:57documentsDOCUMENT_DELETEDasPayload: {"documentId": "uuid"}, butDocumentService.deleteDocumentcallslogAfterCommit(AuditKind.DOCUMENT_DELETED, null, id, null)— the id goes into thedocumentIdcolumn and the payload isnull. Those javadocs are the contract consumers of the audit log read. Either fix the javadoc (Payload: none — documentId column carries the id) or pass the documented payload. The javadoc convention elsewhere in the enum is accurate (e.g.LOGIN_RATE_LIMITED); this entry breaks it.Concerns
deleteDocument(UUID id)is the only audited write path without anactorIdparameter.storeDocument,updateDocument,applyBulkEditToDocument,attachFileall takeUUID actorIdand pass it to the audit call; the newDOCUMENT_DELETEDrow getsactorId = null. Nora will speak to the severity — from the code-consistency side alone, threadingactorIdfrom the controller matches the established pattern and is a small change.Suggestions
log.warnin the listener is defensible (data is being removed as a side effect), and including the count + document id gives the log line everything an operator needs. No change requested — just noting I checked it deliberately.Fix the import and the javadoc and I'm happy.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: 🚫 Changes requested
The fix itself is structurally sound from a security standpoint: the JPQL is fully parameterized (
:documentIdnamed param — no injection surface), the event payload carries only a UUID (no sensitive data crossing the in-process boundary), logging is SLF4J-parameterized with system-generated values only, and no endpoint, permission, orSecurityConfigsurface changes. The synchronous in-transaction listener also fails closed: if the cleanup throws, the whole delete rolls back — no partial state.Blockers
DOCUMENT_DELETEDis written withactorId = null— an audit record for a destructive action that cannot answer "who did it." (DocumentService.java:1107:auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, null, id, null).)This is the inverse of the codebase's own standard:
USER_DELETED,FILE_UPLOADED,STATUS_CHANGED,COMMENT_ADDED,ANNOTATION_CREATEDall record the actor. Document deletion is arguably the most accountability-sensitive operation in a family archive — it destroys an irreplaceable historical record and, with this PR, silently removes journey items as collateral. An audit trail that can show that a document was deleted but not by whom fails its core purpose during incident reconstruction ("who deleted Opa's 1943 letter?").The fix follows the existing pattern — thread the actor through from the controller like every other write path:
And add the regression assertion in
JourneyItemDocumentDeleteTest(AC-7 currently usesany()for the actor slot — it would not catch this):CWE-778 (Insufficient Logging) — not exploitable, but it degrades the control that every other mutation in this system upholds.
Suggestions
AuditKind.java:57javadoc claimsPayload: {"documentId": "uuid"}but the payload isnull. Audit javadocs are the schema your future forensics queries are written against — an inaccurate one causes someone to querypayload->>'documentId'and conclude no documents were ever deleted. Fix the doc or send the payload (Felix flagged the same).Consider including the cascade count in the audit payload (
{"journeyItemsRemoved": n}). The listener already computes it; persisting it would make the audit record self-contained instead of requiring Loki correlation. Optional.One small change, big accountability win. Everything else in this PR I'd hold up as the reference for how to do constraint-collision fixes safely.
🧪 Sara Holt — QA Engineer & Test Strategist
Verdict: ⚠️ Approved with concerns
This is a test suite I'd point people at. Real PostgreSQL via Testcontainers (the
chk_journey_item_not_emptyCHECK constraint is the entire subject of this bug — H2 would have been worthless), every AC has a named test that reads as a sentence, and three techniques deserve explicit praise:@MockitoSpyBean DocumentRepository+doThrowondeleteById— this is the test that pins the@EventListener(not@Async) choice. If anyone ever "optimizes" the listener to async, this test fails. The load-bearing design decision has a load-bearing test.JdbcTemplateinserts fornote = ''— testing a state the service layer can't produce by going under it. Correct layer choice.DOCUMENT_DELETEDemitted) alongside the negative one (JOURNEY_ITEM_REMOVEDabsent) — the ADR even explains why: absence-only assertions pass vacuously when auditing regresses entirely. Someone thought about vacuous truth. Thank you.The
em.clear()discipline between arrange/act/assert phases is consistent, and updating the two existingJourneyItemIntegrationTesttests to route throughDocumentService(rather than deleting them) preserves the placeholder-UX coverage under the new delete path.Concerns
AC-7's actor slot is
any()—verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), any(), eq(doc.getId()), any()). Nora has requestedactorIdbe threaded through; when that lands, tighten this toeq(writer.getId()). As written, the test cannot catch an actor regression — and it didn't (the actor is currentlynull).Every checkbox in the PR test plan is unchecked. The description asserts "all pass" in prose while the boxes say nothing ran. Tick them (or let CI speak). I don't gate on prose.
Cleanup is manual
deleteAll()in@AfterEachrather than@Transactionalrollback — consistent with the existingJourneyItemIntegrationTestpattern, and necessary here since the test exercises real commits across the event boundary, so I accept it. Note the deletion order (journeyItemRepository→docRepo→geschichteRepository) is FK-load-bearing; a comment would save the next person a constraint violation.Test-strategy note
Coverage at the right layer: 8 integration tests for a constraint-interaction bug, zero new E2E. Correct — this bug lives between Hibernate, Spring events, and Postgres constraints; no browser needed. The post-deploy smoke action in the test plan (trigger a previously-500ing delete, watch GlitchTip/Loki) covers the production-config gap that Testcontainers can't.
Tighten the AC-7 verify once the actor fix lands and this is a model PR from the QA side.
🛠️ Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
What I checked: no Compose changes, no Dockerfile changes, no CI workflow changes, no new env vars, no Flyway migration, no new ports or services. The fix is pure application code — exactly the kind of "no schema change, no migration" P1 fix that deploys with zero infrastructure ceremony and rolls back by redeploying the previous JAR. Nothing to size, nothing to firewall.
Things done right from the operations seat:
feat/issue-795-story-documentsinstead ofmainmeans PR #804 cannot ship without this fix — the "⚠️ Merge order" warning in the description is satisfied by the branch topology itself, not by someone remembering to click merge in the right order. That's the correct way to encode a deploy-order constraint. One caveat: if #804's branch gets rebased or this PR lingers, re-verify the base before merging.log.warn("Cascade-deleted {} note-less journey item(s) for document {}", ...)line is an operable signal. It gives me a Loki query (|= "Cascade-deleted") to quantify how often this path fires in production — which directly answers whether the pre-fix 500 was hurting real users. Structured, parameterized, counts included.chk_journey_item_not_emptybeats "I checked the logs and it looks fine."Suggestions
chk_journey_item_not_emptyerror group stops accruing events, then resolve the group — otherwise the stale group muddies the error-rate signal.No infrastructure concerns. LGTM.
📋 Elicit — Requirements Engineer & Business Analyst
Verdict: ⚠️ Approved with concerns
Traceability on this PR is exemplary: AC-1 through AC-7 each map to a named test, the headline test (
deleting_document_linked_via_note_less_item_deletes_item_not_500) states the user-visible outcome, and ADR-038 records why alternatives (RESTRICT, relaxing the CHECK, DB trigger) were rejected in terms of user-facing behavior ("breaks the note-carrying placeholder UX: ... would 409 instead of preserving the item"). That is requirements thinking embedded in an architecture document — exactly where it belongs.I am not reviewing implementation. My scope is whether the specified behavior is the right behavior and what was left unasked. Two open questions:
OQ-1 — Silent collateral deletion: should the curator be told?
Why it matters: With this fix, deleting a document silently removes note-less journey items from any Lesereise that references it. The curator who built that journey gets no warning, no notification, no trace in the journey's history — the item simply vanishes. The behavior is correct (the alternative was a 500), but the requirement "what does the curator experience when their journey loses an item?" was never elicited.
Blocks: nothing in this PR — the P1 must ship.
Recommendation: open a follow-up issue: "When deleting a document, show affected Lesereisen (count of items to be removed / converted to placeholders) in the delete confirmation." The listener already computes the count; the UI just never sees it. Given the product frame — family members curating journeys for relatives — discovering a hole in your journey weeks later erodes trust in the archive.
OQ-2 — Whitespace-only notes survive as placeholders: intended, or accidental precision?
Why it matters: AC-6 specifies
note = ''is cascaded butnote = ' 'is preserved as a "note-carrying placeholder" — a placeholder whose note renders as visually nothing. The ADR documents this boundary honestly, but documents it as a consequence of the predicate, not as an elicited decision. A reader of a journey sees an empty placeholder card and learns nothing.Blocks: nothing — service-layer
normalizeNotemeans this state only arises from legacy/raw-SQL data.Recommendation: record the decision explicitly (one line in the follow-up issue or the ADR): either "whitespace notes are treated as content by design" or "a future data-hygiene pass will null-trim them." Don't let it remain an accident that behaves like a decision.
Minor
AuditKindjavadoc/payload mismatch (flagged by Felix and Nora) is also a documentation requirement defect: the javadoc is the spec for audit consumers. Same fix serves both.Ship the P1; park OQ-1 as a Should for a future release, OQ-2 as a one-line decision record.
🎨 Leonie Voss — UX Design Lead & Accessibility Advocate
Verdict: ✅ Approved
This PR contains no frontend changes — no Svelte components, no routes, no styling, no i18n keys — so there is nothing for me to gate on. What I checked and confirmed:
GlobalExceptionHandler— for our 60+ transcriber audience, that reads as "I broke the archive." After: the delete just works. That alone is a meaningful UX improvement, delivered entirely in the backend.JourneyItemIntegrationTestcases) — a curator's written context survives the document's deletion, which respects the user's authored content. Right priority.ErrorCode→getErrorMessage()→messages/{de,en,es}.jsonchain is needed. Verified: the diff adds no ErrorCode.One forward-looking note, seconding Elicit's OQ-1: journey items now disappear silently when a document is deleted. The curator gets no signal — not at delete time, not when next viewing their journey. When the follow-up lands, the delete confirmation should name the affected journeys in plain language ("Dieses Dokument ist Teil von 2 Lesereisen. Einträge ohne Notiz werden entfernt.") — serif body text, minimum 16px, and a real confirmation button at 44px, not a browser
confirm(). Happy to spec it when that issue is opened.Nothing to fix here. LGTM.
The call site passes a null payload and carries the id in the documentId column (matching FILE_UPLOADED), so the javadoc claiming Payload: {"documentId": "uuid"} misdescribed the audit schema. Audit javadocs are the contract forensic queries are written against. Addresses @felix, @nora and @elicit review on PR #806. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>👨💻 Felix Brandt — Review concerns addressed
Pushed six atomic commits on
feat/issue-805-journey-note-less-cascade(eefc67bd..f6529617). Mapping each to the concern it resolves:Blockers
DOCUMENT_DELETEDwritten withactorId = nulldeleteDocument(UUID id, UUID actorId)threads the actor from the controller viarequireUserId(authentication), matching every other audited write path; AC-7 verify tightened fromany()toeq(writer.getId())c2b98e6eApplicationEventPublisherfieldimport+ simple type namee9b8920bAuditKind.DOCUMENT_DELETEDjavadoc contradicts the call sitePayload: none — the deleted document's id is carried in the documentId column(matches theFILE_UPLOADEDconvention)84c5ff5bl3-backend-3g-supporting.pumlmissing the new componentJourneyItemDocumentDeleteListener+ theDocumentDeletingEventedge (documentSvc → listener) and the listener→DB write6230deaaSuggestions / concerns
@Modifying(clearAutomatically = true, flushAutomatically = true)00baf0d8@AfterEachdeletion sequencef6529617Extra fix found while addressing the actor concern
DocumentServiceTest.deleteDocument_deletesById_whenExistswas already red on the head branch: theeventPublisher.publishEventcall added with the cascade fix lefteventPublishernull under@InjectMocks(no@Mockfor it). The reviewers couldn't have caught it — the PR test plan never listedDocumentServiceTest. Added the missing@Mock ApplicationEventPublisherinc2b98e6e.Verification (all green locally, real Postgres via Testcontainers)
JourneyItemDocumentDeleteTest— 8/8JourneyItemIntegrationTest— 15/15JourneyItemServiceTest+JourneyItemConstraintsTest— 52/52DocumentServiceTest— 187/187 ·DocumentControllerTest— 105/105./mvnw clean package -DskipTests— build clean (261 main + 140 test sources compile)Deliberately not done in this PR
DocumentService, so surfacing it would mean plumbing it back through the event; payload staysnone. Left as a deliberate decision.🤖 Generated with Claude Code