feat(geschichte): restore document management for STORY-type Geschichten (#795) #804

Merged
marcel merged 23 commits from feat/issue-795-story-documents into feat/issue-750-lesereisen-data-model 2026-06-11 19:36:37 +02:00
Owner

Closes #795.

What

Restores the write path for documents on STORY-type Geschichten, closed since V72 dropped geschichten_documents (#753 deferred this to the "future Lesereisen editor" — this is it).

Backend

  • JourneyItemService.append(): JOURNEY-only type guard deleted (per the issue's decision — an allowlist over a two-constant enum would be unreachable dead code under the JaCoCo branch gate). Capacity cap, dedup guard, and note validation unchanged.
  • "Journey not found" → "Geschichte not found" in append()/reorder() — error strings no longer claim a check the code doesn't perform.
  • GESCHICHTE_TYPE_MISMATCH removed end to end: ErrorCode.java, errors.ts union + getErrorMessage() case, i18n keys in de/en/es. Verified zero mentions remain repo-wide; GESCHICHTE_TYPE_IMMUTABLE untouched.

Frontend

  • New StoryDocumentPanel.svelte in the story editor sidebar: position-sorted item list, pessimistic add via POST /items, optimistic remove with snapshot-rollback via DELETE /items/{id}, both through csrfFetch. Deleted-document items render as removable placeholder rows. 409 capacity/duplicate map to story-worded messages; everything else through getErrorMessage(). Polite live region + focus management on remove. Sidebar p-4 section style, mobile <details> accordion, no inner scroll clamp.
  • DocumentPickerDropdown: optional inputId prop so the panel can wire a visible <label for>; generated default id; JourneyAddBar byte-for-byte untouched.
  • GeschichteSidebar gains optional geschichteId/items props; GeschichteEditor derives both from its GeschichteView prop — panel absent on /geschichten/new and for journeys.

Tests

  • Backend: 3 STORY append unit tests (written red against the guard), story() factory, 3 new integration tests (STORY e2e, V72-style position-gap rows incl. removal, dangling deleted-document item). 67/67 green across the three journeyitem classes.
  • Frontend: 14-test StoryDocumentPanel spec, sidebar contract spec, editor + StoryCreate guards, 2 picker inputId tests. 79/79 across the six touched spec files. Lint clean, new files svelte-check clean.

Notes for reviewers

  • Two documented deviations from the issue text (outdated stale-schema premise → editor-derived props, confirmed with Marcel; focus to picker input instead of the mobile-hidden heading) — details in the completion comment.
  • Follow-up issue candidate found during testing: V72's ON DELETE SET NULL collides with chk_journey_item_not_empty for note-less items — deleting such a linked document 500s at the DB. Pre-existing, not touched here.
  • i18n commit collapsed three pre-existing exact-duplicate keys per locale (identical values).

🤖 Generated with Claude Code

Closes #795. ## What Restores the write path for documents on STORY-type Geschichten, closed since V72 dropped `geschichten_documents` (#753 deferred this to the "future Lesereisen editor" — this is it). ### Backend - `JourneyItemService.append()`: JOURNEY-only type guard **deleted** (per the issue's decision — an allowlist over a two-constant enum would be unreachable dead code under the JaCoCo branch gate). Capacity cap, dedup guard, and note validation unchanged. - "Journey not found" → "Geschichte not found" in `append()`/`reorder()` — error strings no longer claim a check the code doesn't perform. - `GESCHICHTE_TYPE_MISMATCH` removed end to end: `ErrorCode.java`, `errors.ts` union + `getErrorMessage()` case, i18n keys in de/en/es. Verified zero mentions remain repo-wide; `GESCHICHTE_TYPE_IMMUTABLE` untouched. ### Frontend - New **`StoryDocumentPanel.svelte`** in the story editor sidebar: position-sorted item list, pessimistic add via `POST /items`, optimistic remove with snapshot-rollback via `DELETE /items/{id}`, both through `csrfFetch`. Deleted-document items render as removable placeholder rows. 409 capacity/duplicate map to story-worded messages; everything else through `getErrorMessage()`. Polite live region + focus management on remove. Sidebar `p-4` section style, mobile `<details>` accordion, no inner scroll clamp. - `DocumentPickerDropdown`: optional `inputId` prop so the panel can wire a visible `<label for>`; generated default id; `JourneyAddBar` byte-for-byte untouched. - `GeschichteSidebar` gains optional `geschichteId`/`items` props; `GeschichteEditor` derives both from its `GeschichteView` prop — panel absent on `/geschichten/new` and for journeys. ## Tests - Backend: 3 STORY append unit tests (written red against the guard), `story()` factory, 3 new integration tests (STORY e2e, V72-style position-gap rows incl. removal, dangling deleted-document item). 67/67 green across the three journeyitem classes. - Frontend: 14-test `StoryDocumentPanel` spec, sidebar contract spec, editor + StoryCreate guards, 2 picker `inputId` tests. 79/79 across the six touched spec files. Lint clean, new files svelte-check clean. ## Notes for reviewers - Two documented deviations from the issue text (outdated stale-schema premise → editor-derived props, confirmed with Marcel; focus to picker input instead of the mobile-hidden heading) — details in the [completion comment](https://git.raddatz.cloud/marcel/familienarchiv/issues/795#issuecomment-16313). - Follow-up issue candidate found during testing: V72's `ON DELETE SET NULL` collides with `chk_journey_item_not_empty` for note-less items — deleting such a linked document 500s at the DB. Pre-existing, not touched here. - i18n commit collapsed three pre-existing exact-duplicate keys per locale (identical values). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 6 commits 2026-06-11 12:51:20 +02:00
Delete the JOURNEY-only type guard in JourneyItemService.append() so the
existing item endpoints serve both Geschichte types. GeschichteType has
exactly two constants, so an allowlist replacement would be unreachable
dead code. Fix the not-found messages that claimed "Journey", and remove
the now-orphaned GESCHICHTE_TYPE_MISMATCH error code end to end
(ErrorCode, errors.ts union + mapping, i18n keys in de/en/es).

Tests: three STORY append unit tests written red against the guard, plus
end-to-end STORY coverage (append+retrieve, V72-style position-gap rows
incl. removal, dangling document-deleted item). The two STORY-rejection
tests die with the guard — no third enum value exists to feed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The input always carries an id (generated doc-picker-input-{uid} default,
mirroring the listbox id scheme). When a caller passes inputId it wires a
visible <label for> — the aria-label fallback is dropped then so the
visible label wins. JourneyAddBar stays untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Panel header, hint, empty state, picker label + placeholder, deleted-
document placeholder, remove-button label, live-region announcements,
and the story-worded capacity/duplicate errors (the generic
JOURNEY_AT_CAPACITY / JOURNEY_DOCUMENT_ALREADY_ADDED messages say
"Lesereise" — the wrong frame inside a STORY panel).

Side effect: the JSON round-trip collapsed three pre-existing duplicate
keys per locale (identical values, last-wins either way) —
error_geschichte_title_too_long, error_geschichte_intro_too_long,
person_unknown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sidebar-section-styled panel (p-4 card, mobile <details> accordion, no
inner scroll clamp) that lists a story's journey items in position order.
Add is pessimistic via POST /items; remove is optimistic with snapshot
rollback via DELETE /items/{id}; both through csrfFetch. Already-linked
documents are unselectable in the reused DocumentPickerDropdown (visible
label wired via inputId). Document-less items (ON DELETE SET NULL)
render as removable placeholder rows. 409 capacity/duplicate map to
story-worded messages, everything else through getErrorMessage(). Add/
remove are announced in a polite live region and focus moves to the
previous row's remove button (picker input when the list empties).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GeschichteSidebar gains optional geschichteId/items props and renders the
panel only when geschichteId is set. GeschichteEditor derives both from
its existing GeschichteView prop — null on /geschichten/new, so the panel
stays hidden there (create-then-edit, same as journeys). JourneyEditor's
sidebar call site is untouched, so journeys never show the panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs(geschichte): add StoryDocumentPanel to component inventory + C4 diagram (#795)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m9s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 4m23s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
2121d8469f
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

⚠️ Approved with concerns

Nothing in this PR touches the parts I operate — and I verified that, rather than taking the description's word for it.

What I checked across all 19 changed files:

  • No Flyway migration — confirmed. No files under db/migration, no schema change. The feature rides entirely on the existing V72 journey_items table. Nothing to sequence at deploy time, no migration to test against a clean database beyond what CI already runs.
  • No env/config changes — confirmed. No docker-compose*.yml, no application*.yml, no CI workflow edits, no new dependencies in pom.xml or package-lock.json. Deploy is a plain image swap.
  • Rollback safety — good. The change is permissive (a type guard is deleted, no data shape changes). Rolling back to the previous backend re-arms the JOURNEY-only guard and STORY items become read-only again — no data is stranded or corrupted. The removed GESCHICHTE_TYPE_MISMATCH code is symmetric front/back, so neither version emits a code the other can't map. Clean.
  • CI impact — the stated reason for deleting the guard instead of allowlisting (unreachable branch under the JaCoCo gate) is the right call; dead branches are how coverage gates rot. New integration tests exercise the real Postgres path including the V72 position-gap and ON DELETE SET NULL scenarios — that's the kind of test that would have caught a migration regression.
  • i18n duplicate-key collapse — collapsing the three pre-existing duplicate keys per locale is a small build-hygiene win; duplicate JSON keys are silent last-wins behavior that bites during message regeneration.

Blockers

None.

Suggestions

  1. File the follow-up issue for the chk_journey_item_not_empty / ON DELETE SET NULL collision before merging — not after. The PR notes it as "pre-existing", which is true, but this PR materially widens its blast radius: until now only journey curators could create note-less items; after merge, every STORY editor adding documents via StoryDocumentPanel.svelte creates exactly the note-less rows that make a later document delete 500 at the DB constraint. The integration test (JourneyItemIntegrationTest.java, story_item_with_deleted_document_survives_and_remains_deletable) even has to work around it with a note — the comment in that test documents the bug. That's a known, reproducible production 500 that will land in GlitchTip with no tracked issue behind it. An alert (or error report) without a runbook is noise; same goes for a known 500 without an issue number.
  2. Frontend console.error on failed mutations (StoryDocumentPanel.svelte, handleAdd/handleRemove catch blocks) — fine for now, but if VITE_SENTRY_DSN is set these network failures vanish into the browser console instead of GlitchTip. Consider routing through the existing Sentry capture path in a follow-up; not worth blocking a feature PR over.

Good, boring deploy. That's a compliment.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **⚠️ Approved with concerns** Nothing in this PR touches the parts I operate — and I verified that, rather than taking the description's word for it. What I checked across all 19 changed files: - **No Flyway migration** — confirmed. No files under `db/migration`, no schema change. The feature rides entirely on the existing V72 `journey_items` table. Nothing to sequence at deploy time, no migration to test against a clean database beyond what CI already runs. - **No env/config changes** — confirmed. No `docker-compose*.yml`, no `application*.yml`, no CI workflow edits, no new dependencies in `pom.xml` or `package-lock.json`. Deploy is a plain image swap. - **Rollback safety** — good. The change is permissive (a type guard is deleted, no data shape changes). Rolling back to the previous backend re-arms the JOURNEY-only guard and STORY items become read-only again — no data is stranded or corrupted. The removed `GESCHICHTE_TYPE_MISMATCH` code is symmetric front/back, so neither version emits a code the other can't map. Clean. - **CI impact** — the stated reason for deleting the guard instead of allowlisting (unreachable branch under the JaCoCo gate) is the right call; dead branches are how coverage gates rot. New integration tests exercise the real Postgres path including the V72 position-gap and `ON DELETE SET NULL` scenarios — that's the kind of test that would have caught a migration regression. - **i18n duplicate-key collapse** — collapsing the three pre-existing duplicate keys per locale is a small build-hygiene win; duplicate JSON keys are silent last-wins behavior that bites during message regeneration. ### Blockers None. ### Suggestions 1. **File the follow-up issue for the `chk_journey_item_not_empty` / `ON DELETE SET NULL` collision before merging — not after.** The PR notes it as "pre-existing", which is true, but this PR materially widens its blast radius: until now only journey curators could create note-less items; after merge, every STORY editor adding documents via `StoryDocumentPanel.svelte` creates exactly the note-less rows that make a later document delete 500 at the DB constraint. The integration test (`JourneyItemIntegrationTest.java`, `story_item_with_deleted_document_survives_and_remains_deletable`) even has to work around it with a note — the comment in that test documents the bug. That's a known, reproducible production 500 that will land in GlitchTip with no tracked issue behind it. An alert (or error report) without a runbook is noise; same goes for a known 500 without an issue number. 2. **Frontend `console.error` on failed mutations** (`StoryDocumentPanel.svelte`, `handleAdd`/`handleRemove` catch blocks) — fine for now, but if `VITE_SENTRY_DSN` is set these network failures vanish into the browser console instead of GlitchTip. Consider routing through the existing Sentry capture path in a follow-up; not worth blocking a feature PR over. Good, boring deploy. That's a compliment.
Author
Owner

🏛️ Markus Keller — Application Architect (SvelteKit · Spring Boot · PostgreSQL)

🚫 Changes requested — the code is architecturally sound, but two architecture documents now contradict the code. Per our rule, a doc omission is a blocker, not a concern.

What I checked

  • Module boundaries: JourneyItemService still reaches Geschichte data only through GeschichteQueryService — boundary intact after the guard removal. StoryDocumentPanel consumes DocumentPickerDropdown through a clean published-prop extension (inputId); JourneyAddBar untouched. Good.
  • Database-layer integrity: the removed application guard does not weaken integrity — dedup is enforced by the V74 partial unique index uq_journey_items_geschichte_document, positions by V73's DEFERRABLE unique. Deleting the unreachable type guard rather than keeping dead-branch code is the right call; the schema never encoded a JOURNEY-only restriction, so the application check was the only place the rule lived, and the product decision (#795) removed the rule.
  • Error-code lifecycle: GESCHICHTE_TYPE_MISMATCH removed in all four mandated places (ErrorCode.java, errors.ts union, getErrorMessage() case, de/en/es keys). I grepped the branch — zero mentions remain. The duplicate-key cleanup in the three locale files is welcome.
  • Doc currency: frontend C4 (l3-frontend-3c-people-stories.puml), frontend/src/lib/geschichte/README.md updated. Backend docs were not — see blockers.
  • No new migrations, routes, infra, or external systems — no further table rows triggered.

Blockers

  1. docs/architecture/c4/l3-backend-3g-supporting.puml:22JourneyItemService component still says "Enforces JOURNEY-type guard on append." This PR deletes exactly that guard. Same file, line 44: Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type") — the type check is gone; it now checks existence only. The backend diagram must match the code before merge ("new/changed service behavior in an existing backend domain → matching l3-backend-*.puml").

  2. docs/GLOSSARY.md:154 and docs/ARCHITECTURE.md:41 — both still define JourneyItem as belonging exclusively to JOURNEY-type Geschichten ("a single stop in a Lesereise (Geschichte with type=JOURNEY)" / "ordered stops in a JOURNEY-type Geschichte"). After this PR, journey_items is the document-attachment mechanism for both subtypes — that is the central domain-model change of the PR and the glossary is the canonical place it must be recorded. Update both entries (and GLOSSARY.md:152's STORY description, which currently implies stories link only persons).

Suggestions

  1. Capture the "journey_items serves both subtypes" decision in the repo, not only in issue #795. A future developer reading JourneyItem, JOURNEY_AT_CAPACITY, and JOURNEY_DOCUMENT_ALREADY_ADDED will reasonably conclude stories should not have items and may re-add the guard. A short ADR (docs/adr/) — context: V72 dropped geschichten_documents; decision: one shared item table, no type guard; alternative rejected: separate story-documents join table — is cheap insurance. The blocker-2 glossary fix covers the what; the ADR covers the why.

  2. Naming drift is contained but real: StoryDocumentPanel.failureMessage() re-words two journey-flavored 409 codes for story context. That presentation-layer mapping is the pragmatic choice today (renaming enum constants would ripple through ErrorCode.java, errors.ts, and three locales for zero behavior change), but note it in the ADR from suggestion 1 so the asymmetry is a documented decision, not an accident.

  3. File the follow-up issue for the ON DELETE SET NULL × chk_journey_item_not_empty collision now, before it leaves working memory. A DB-level constraint conflict that 500s a document delete is exactly the class of integrity bug we push to the schema to prevent — the V72 FK action and the V72 CHECK constraint encode contradictory rules for note-less items. Likely fix direction: delete the row instead of nulling the FK (trigger or application-side cascade), or relax the CHECK. Pre-existing and correctly out of scope here, but it needs a tracked issue, not a PR footnote.

Fix the two doc blockers and this is an approve — the service-layer changes, DB-constraint posture, and frontend component ownership are all clean.

## 🏛️ Markus Keller — Application Architect (SvelteKit · Spring Boot · PostgreSQL) **🚫 Changes requested** — the code is architecturally sound, but two architecture documents now contradict the code. Per our rule, a doc omission is a blocker, not a concern. ### What I checked - **Module boundaries**: `JourneyItemService` still reaches Geschichte data only through `GeschichteQueryService` — boundary intact after the guard removal. `StoryDocumentPanel` consumes `DocumentPickerDropdown` through a clean published-prop extension (`inputId`); `JourneyAddBar` untouched. Good. - **Database-layer integrity**: the removed application guard does not weaken integrity — dedup is enforced by the V74 partial unique index `uq_journey_items_geschichte_document`, positions by V73's DEFERRABLE unique. Deleting the unreachable type guard rather than keeping dead-branch code is the right call; the schema never encoded a JOURNEY-only restriction, so the application check was the only place the rule lived, and the product decision (#795) removed the rule. - **Error-code lifecycle**: `GESCHICHTE_TYPE_MISMATCH` removed in all four mandated places (ErrorCode.java, errors.ts union, `getErrorMessage()` case, de/en/es keys). I grepped the branch — zero mentions remain. The duplicate-key cleanup in the three locale files is welcome. - **Doc currency**: frontend C4 (`l3-frontend-3c-people-stories.puml`), `frontend/src/lib/geschichte/README.md` updated. Backend docs were **not** — see blockers. - **No new migrations, routes, infra, or external systems** — no further table rows triggered. ### Blockers 1. **`docs/architecture/c4/l3-backend-3g-supporting.puml:22`** — `JourneyItemService` component still says *"Enforces JOURNEY-type guard on append."* This PR deletes exactly that guard. Same file, **line 44**: `Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type")` — the type check is gone; it now checks existence only. The backend diagram must match the code before merge ("new/changed service behavior in an existing backend domain → matching `l3-backend-*.puml`"). 2. **`docs/GLOSSARY.md:154` and `docs/ARCHITECTURE.md:41`** — both still define `JourneyItem` as belonging exclusively to JOURNEY-type Geschichten (*"a single stop in a Lesereise (`Geschichte` with `type=JOURNEY`)"* / *"ordered stops in a JOURNEY-type Geschichte"*). After this PR, `journey_items` is the document-attachment mechanism for **both** subtypes — that is the central domain-model change of the PR and the glossary is the canonical place it must be recorded. Update both entries (and `GLOSSARY.md:152`'s STORY description, which currently implies stories link only persons). ### Suggestions 1. **Capture the "journey_items serves both subtypes" decision in the repo, not only in issue #795.** A future developer reading `JourneyItem`, `JOURNEY_AT_CAPACITY`, and `JOURNEY_DOCUMENT_ALREADY_ADDED` will reasonably conclude stories should not have items and may re-add the guard. A short ADR (`docs/adr/`) — context: V72 dropped `geschichten_documents`; decision: one shared item table, no type guard; alternative rejected: separate story-documents join table — is cheap insurance. The blocker-2 glossary fix covers the *what*; the ADR covers the *why*. 2. **Naming drift is contained but real**: `StoryDocumentPanel.failureMessage()` re-words two journey-flavored 409 codes for story context. That presentation-layer mapping is the pragmatic choice today (renaming enum constants would ripple through ErrorCode.java, errors.ts, and three locales for zero behavior change), but note it in the ADR from suggestion 1 so the asymmetry is a documented decision, not an accident. 3. **File the follow-up issue for the `ON DELETE SET NULL` × `chk_journey_item_not_empty` collision now**, before it leaves working memory. A DB-level constraint conflict that 500s a document delete is exactly the class of integrity bug we push to the schema to *prevent* — the V72 FK action and the V72 CHECK constraint encode contradictory rules for note-less items. Likely fix direction: delete the row instead of nulling the FK (trigger or application-side cascade), or relax the CHECK. Pre-existing and correctly out of scope here, but it needs a tracked issue, not a PR footnote. Fix the two doc blockers and this is an approve — the service-layer changes, DB-constraint posture, and frontend component ownership are all clean.
Author
Owner

🎨 Leonie Voss — UI/UX Design Lead, Brand Specialist, Accessibility Advocate

Approved

This is what I want every sidebar panel PR to look like. I checked brand compliance, a11y (labels, live regions, focus management, touch targets), 320px behavior, and the de/en/es copy.

Blockers

None.

What I verified

  • Visual consistencyStoryDocumentPanel.svelte reproduces the sibling sidebar sections byte-for-byte: <details open class="sm:contents"> mobile accordion with a 44px <summary>, section card rounded border border-line bg-surface p-4 shadow-sm, hidden-below-sm h2 with font-sans text-xs font-bold tracking-widest text-ink-3 uppercase. Side-by-side with the Status and Personen sections in GeschichteSidebar.svelte:30-76 it is indistinguishable. Serif for document titles, sans for chrome — correct per the type system.
  • Form labeling — the inputId prop on DocumentPickerDropdown.svelte:14 plus aria-label={inputId ? undefined : placeholder} is exactly right: a visible <label for> wins when wired, the aria-label fallback stays for JourneyAddBar. Both paths have spec coverage. This is the pattern I'd ask other pickers to adopt.
  • Focus managementmoveFocusAfterRemove() (StoryDocumentPanel.svelte:~95) prevents the classic focus-drops-to-<body> teleport: previous row's remove button, picker input when the list empties. Both covered by browser specs asserting document.activeElement.
  • Live regionaria-live="polite" aria-atomic="true" sr-only region with add/remove announcements, plus role="alert" for errors. Errors are text (not color-alone), use border-danger bg-danger/10 text-danger semantic tokens that remap in dark mode.
  • Touch targets — remove button h-11 min-w-[44px] = 44px, WCAG 2.5.8 pass; focus:outline-none is paired with focus-visible:ring-2 focus-visible:ring-focus-ring (a real token with a dark-mode remap in layout.css:137/242), so keyboard users keep a visible indicator.
  • i18n — all 11 new keys present and idiomatic in de/en/es; German keeps the editor's du-register ("Suche unten nach einem Brief…"), Spanish uses recarga/quitar consistently with neighbors; the story-worded 409 overrides ("Diese Geschichte…" instead of "Die Lesereise…") fix exactly the wording mismatch I would have flagged. Removing the three duplicate keys per locale is a nice bonus. Deleted-doc placeholder is italic + text, not color-alone.
  • Mobile/320pxmin-w-0 flex-1 truncate on title spans prevents row overflow; no fixed widths anywhere; no inner scroll clamp so the page scrolls naturally.

Suggestions

  1. Focus after a failed remove rollbackhandleRemove() moves focus before the DELETE; if the request fails and the row is restored, focus stays on the neighbor's remove button rather than returning to the restored row's. The role="alert" covers the announcement, so this is Low priority — but if you touch this file again, consider re-focusing [data-item-id="${item.id}"] [data-remove-btn] in the rollback branch.
  2. announceTimer cleanup — the 500ms timer isn't cleared on destroy (StoryDocumentPanel.svelte:~37). Harmless today since it only writes $state, but an onDestroy(() => announceTimer && clearTimeout(announceTimer)) is a one-liner. Low.
  3. Error alert icon — the error <p role="alert"> is text + color; adding the small warning icon used elsewhere would give the third redundant cue for low-vision users scanning the sidebar. Low.

Nothing here warrants holding the PR. Nice work — the senior transcriber on a tablet will be able to use this panel without a mouse, and that's the bar.

## 🎨 Leonie Voss — UI/UX Design Lead, Brand Specialist, Accessibility Advocate **✅ Approved** This is what I want every sidebar panel PR to look like. I checked brand compliance, a11y (labels, live regions, focus management, touch targets), 320px behavior, and the de/en/es copy. ### Blockers None. ### What I verified - **Visual consistency** — `StoryDocumentPanel.svelte` reproduces the sibling sidebar sections byte-for-byte: `<details open class="sm:contents">` mobile accordion with a 44px `<summary>`, `section` card `rounded border border-line bg-surface p-4 shadow-sm`, hidden-below-sm `h2` with `font-sans text-xs font-bold tracking-widest text-ink-3 uppercase`. Side-by-side with the Status and Personen sections in `GeschichteSidebar.svelte:30-76` it is indistinguishable. Serif for document titles, sans for chrome — correct per the type system. - **Form labeling** — the `inputId` prop on `DocumentPickerDropdown.svelte:14` plus `aria-label={inputId ? undefined : placeholder}` is exactly right: a visible `<label for>` wins when wired, the aria-label fallback stays for `JourneyAddBar`. Both paths have spec coverage. This is the pattern I'd ask other pickers to adopt. - **Focus management** — `moveFocusAfterRemove()` (StoryDocumentPanel.svelte:~95) prevents the classic focus-drops-to-`<body>` teleport: previous row's remove button, picker input when the list empties. Both covered by browser specs asserting `document.activeElement`. - **Live region** — `aria-live="polite" aria-atomic="true"` sr-only region with add/remove announcements, plus `role="alert"` for errors. Errors are text (not color-alone), use `border-danger bg-danger/10 text-danger` semantic tokens that remap in dark mode. - **Touch targets** — remove button `h-11 min-w-[44px]` = 44px, WCAG 2.5.8 pass; `focus:outline-none` is paired with `focus-visible:ring-2 focus-visible:ring-focus-ring` (a real token with a dark-mode remap in `layout.css:137/242`), so keyboard users keep a visible indicator. - **i18n** — all 11 new keys present and idiomatic in de/en/es; German keeps the editor's du-register ("Suche unten nach einem Brief…"), Spanish uses recarga/quitar consistently with neighbors; the story-worded 409 overrides ("Diese Geschichte…" instead of "Die Lesereise…") fix exactly the wording mismatch I would have flagged. Removing the three duplicate keys per locale is a nice bonus. Deleted-doc placeholder is italic + text, not color-alone. - **Mobile/320px** — `min-w-0 flex-1 truncate` on title spans prevents row overflow; no fixed widths anywhere; no inner scroll clamp so the page scrolls naturally. ### Suggestions 1. **Focus after a failed remove rollback** — `handleRemove()` moves focus *before* the DELETE; if the request fails and the row is restored, focus stays on the neighbor's remove button rather than returning to the restored row's. The `role="alert"` covers the announcement, so this is Low priority — but if you touch this file again, consider re-focusing `[data-item-id="${item.id}"] [data-remove-btn]` in the rollback branch. 2. **`announceTimer` cleanup** — the 500ms timer isn't cleared on destroy (`StoryDocumentPanel.svelte:~37`). Harmless today since it only writes `$state`, but an `onDestroy(() => announceTimer && clearTimeout(announceTimer))` is a one-liner. Low. 3. **Error alert icon** — the error `<p role="alert">` is text + color; adding the small warning icon used elsewhere would give the third redundant cue for low-vision users scanning the sidebar. Low. Nothing here warrants holding the PR. Nice work — the senior transcriber on a tablet will be able to use this panel without a mouse, and that's the bar.
Author
Owner

👨‍💻 Felix Brandt — Fullstack Developer

Approved

What I checked: TDD evidence, naming, function size, guard clauses, Svelte 5 idioms, component boundaries, dead code. The diff plus full reads of JourneyItemService.java, StoryDocumentPanel.svelte, JourneyEditor.svelte, and an i18n-key usage sweep.

What's good here:

  • TDD evidence is real. The three STORY append unit tests were written red against the type guard before its deletion, and they pin exactly the behavior the issue asks for (append_to_STORY_type_creates_journey_item, capacity cap, dedup). Frontend coverage hits all four states — populated, empty, error (role="alert" + rollback), and the deleted-document placeholder — plus the two focus-management paths. That's the full matrix, not just the happy path.
  • Guard-clause discipline held. append() after the guard removal still reads top-down: not-found → capacity → note validation → dedup → persist. The error-string fix ("Journey not found" → "Geschichte not found") is exactly right — the message no longer claims a check the code doesn't perform.
  • GESCHICHTE_TYPE_MISMATCH removal is complete. I grepped backend, frontend, and all three locale files independently — zero mentions remain, GESCHICHTE_TYPE_IMMUTABLE untouched. Deleting an unreachable guard instead of keeping ceremonial dead code is the right call.
  • Svelte 5 idioms: keyed {#each items as item (item.id)}, $derived for alreadyAddedIds (recreated Set, so no SvelteSet needed), $props.id() for the label wiring, snapshot-and-rollback on optimistic remove. The svelte-ignore state_referenced_locally is justified and documented with the re-mount contract.
  • The DocumentPickerDropdown change is minimal and contract-tested both ways (generated default id + aria-label fallback; external id drops the aria-label so the visible <label for> wins). JourneyAddBar untouched as promised.
  • Docs updated where they should be: domain README table, the l3 puml component description.

Blockers

None.

Suggestions

  1. Dead i18n keys adjacent to your new onesgeschichte_editor_dokumente_heading / geschichte_editor_dokumente_hint (de/en/es, e.g. frontend/messages/de.json:1080-1081) have zero usages in frontend/src. They sit directly below the new geschichte_documents_* block and say nearly the same thing ("Welche Briefe oder Dokumente sind Teil dieser Geschichte?" vs. your new hint). Since this PR already collapsed three duplicate keys per locale, deleting these two while you're in the file would finish the cleanup — fine as a follow-up if you'd rather keep this commit scoped.

  2. failureMessage() now exists twiceJourneyEditor.svelte:93-96 and StoryDocumentPanel.svelte:52-56 share the same core (res.json().catch → code → getErrorMessage(code) : journey_mutation_error_reload()); the panel version adds two story-worded 409 cases on top. Two occurrences is below my extract threshold, so leaving it is fine — but if a third consumer shows up, pull the base mapper into $lib/geschichte/utils.ts and let callers override the 409 wording.

  3. StoryDocumentPanel.svelte is 193 lines — over my 60-line splitting signal. It's arguably one visual region, but the item row (title/placeholder span + remove button + inline SVG, lines ~134-163) is a nameable boundary: StoryDocumentRow.svelte with item, onRemove props would bring the panel under ~130 and make the row independently testable. Question, not a demand — the JourneyItemRow/JourneyEditor split next door is the precedent.

  4. announceTimer never cleared on unmount (StoryDocumentPanel.svelte:28-34) — a remove immediately before navigating away leaves a 500ms timeout firing into a destroyed component. Harmless in Svelte 5 (it just writes a $state nobody reads), but a one-line $effect(() => () => { if (announceTimer) clearTimeout(announceTimer); }) closes it.

Clean work — the truthful error messages and the end-to-end dead-code removal are exactly how I'd want this done.

## 👨‍💻 Felix Brandt — Fullstack Developer **✅ Approved** What I checked: TDD evidence, naming, function size, guard clauses, Svelte 5 idioms, component boundaries, dead code. The diff plus full reads of `JourneyItemService.java`, `StoryDocumentPanel.svelte`, `JourneyEditor.svelte`, and an i18n-key usage sweep. What's good here: - **TDD evidence is real.** The three STORY append unit tests were written red against the type guard before its deletion, and they pin exactly the behavior the issue asks for (`append_to_STORY_type_creates_journey_item`, capacity cap, dedup). Frontend coverage hits all four states — populated, empty, error (`role="alert"` + rollback), and the deleted-document placeholder — plus the two focus-management paths. That's the full matrix, not just the happy path. - **Guard-clause discipline held.** `append()` after the guard removal still reads top-down: not-found → capacity → note validation → dedup → persist. The error-string fix ("Journey not found" → "Geschichte not found") is exactly right — the message no longer claims a check the code doesn't perform. - **`GESCHICHTE_TYPE_MISMATCH` removal is complete.** I grepped backend, frontend, and all three locale files independently — zero mentions remain, `GESCHICHTE_TYPE_IMMUTABLE` untouched. Deleting an unreachable guard instead of keeping ceremonial dead code is the right call. - **Svelte 5 idioms:** keyed `{#each items as item (item.id)}`, `$derived` for `alreadyAddedIds` (recreated Set, so no `SvelteSet` needed), `$props.id()` for the label wiring, snapshot-and-rollback on optimistic remove. The `svelte-ignore state_referenced_locally` is justified and documented with the re-mount contract. - The `DocumentPickerDropdown` change is minimal and contract-tested both ways (generated default id + aria-label fallback; external id drops the aria-label so the visible `<label for>` wins). `JourneyAddBar` untouched as promised. - Docs updated where they should be: domain README table, the l3 puml component description. ### Blockers None. ### Suggestions 1. **Dead i18n keys adjacent to your new ones** — `geschichte_editor_dokumente_heading` / `geschichte_editor_dokumente_hint` (de/en/es, e.g. `frontend/messages/de.json:1080-1081`) have zero usages in `frontend/src`. They sit directly below the new `geschichte_documents_*` block and say nearly the same thing ("Welche Briefe oder Dokumente sind Teil dieser Geschichte?" vs. your new hint). Since this PR already collapsed three duplicate keys per locale, deleting these two while you're in the file would finish the cleanup — fine as a follow-up if you'd rather keep this commit scoped. 2. **`failureMessage()` now exists twice** — `JourneyEditor.svelte:93-96` and `StoryDocumentPanel.svelte:52-56` share the same core (`res.json().catch → code → getErrorMessage(code) : journey_mutation_error_reload()`); the panel version adds two story-worded 409 cases on top. Two occurrences is below my extract threshold, so leaving it is fine — but if a third consumer shows up, pull the base mapper into `$lib/geschichte/utils.ts` and let callers override the 409 wording. 3. **`StoryDocumentPanel.svelte` is 193 lines** — over my 60-line splitting signal. It's arguably one visual region, but the item row (title/placeholder span + remove button + inline SVG, lines ~134-163) is a nameable boundary: `StoryDocumentRow.svelte` with `item`, `onRemove` props would bring the panel under ~130 and make the row independently testable. Question, not a demand — the JourneyItemRow/JourneyEditor split next door is the precedent. 4. **`announceTimer` never cleared on unmount** (`StoryDocumentPanel.svelte:28-34`) — a remove immediately before navigating away leaves a 500ms timeout firing into a destroyed component. Harmless in Svelte 5 (it just writes a `$state` nobody reads), but a one-line `$effect(() => () => { if (announceTimer) clearTimeout(announceTimer); })` closes it. Clean work — the truthful error messages and the end-to-end dead-code removal are exactly how I'd want this done.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer · Ethical Hacker

Approved

I reviewed the full diff plus the surrounding controller, service, CSRF helper, and SecurityConfig on feat/issue-795-story-documents. No vulnerability found. What I checked, and why each area passes:

Blockers

None.

Authorization (the headline risk of this PR — guard removal). The deleted JOURNEY-type check in JourneyItemService.append() was a domain-shape guard, not a security boundary. Every mutating endpoint that the new panel calls remains declaratively protected: POST /{id}/items, PATCH /{id}/items/{itemId}, DELETE /{id}/items/{itemId}, PUT /{id}/items/reorder all carry @RequirePermission(Permission.BLOG_WRITE) (GeschichteController.java:76-104). Removing the type guard widens what a BLOG_WRITE user can do within their own permission class — it grants nothing to anyone below that. No privilege escalation.

IDOR. delete() and updateNote() scope the item to the URL's Geschichte via findByIdAndGeschichteId(itemId, geschichteId) (JourneyItemService.java:111, 145) — an attacker cannot delete item X of story A through story B's URL. reorder() validates the requested ID set against findIdsByGeschichteId exactly (JourneyItemService.java:170-173), so cross-Geschichte item IDs are rejected. Correct pattern.

CSRF. Both panel mutations go through csrfFetch (StoryDocumentPanel.svelte:62, 99), which attaches the token for POST/PUT/PATCH/DELETE (cookies.ts:53-59). No raw fetch for writes.

XSS. item.document.title and the deleted-document placeholder render via Svelte text interpolation only — auto-escaped. No {@html}, no innerHTML anywhere in the new component. The aria-labels and live-region announcements interpolate titles through Paraglide message functions into text nodes — same story.

Information disclosure in errors. The reworded "Geschichte not found: {uuid}" messages echo only a Spring-parsed UUID path variable — type-constrained, no injection or reflection surface. failureMessage() in the panel maps only known codes; unknown codes fall through getErrorMessage() whose default: returns a generic message (errors.ts:211-212) — no raw server detail reaches the DOM. Error-string honesty also improved: the message no longer claims a journey check the code doesn't perform — that matters during incident response.

Audit trail. Add/remove/note/reorder all log actorId after commit (JourneyItemService.java:102-104, 151-153). The new STORY write path inherits this for free.

DoS posture. The 100-item cap and the DB-level dedup index backstop (uq_journey_items_geschichte_document, saveAndFlush at JourneyItemService.java:90) survive the guard removal — the TOCTOU window between the exists() pre-check and insert stays closed at the constraint.

Suggestions

  1. Hardening (smell, not a vuln): moveFocusAfterRemove interpolates target.id into a querySelector attribute selector (StoryDocumentPanel.svelte:88). Today the value is a server-issued UUID, so it's inert — but if this pattern gets copy-pasted somewhere with user-influenced IDs, it becomes a selector-injection foothold. Cheap immunization: CSS.escape(target.id). Detection note: a Semgrep rule matching querySelector(\...${...}...`)withoutCSS.escape` would catch the whole class.

  2. Pre-existing, for awareness: the authorization model is coarse — any BLOG_WRITE holder can mutate any author's story items. That's the established design of this family-internal archive (consistent with update/delete on the Geschichte itself), not something this PR introduces or worsens. No action needed here; just flagging that per-author ownership, if ever wanted, belongs in a dedicated issue.

  3. The follow-up you noted (note-less item: ON DELETE SET NULL vs chk_journey_item_not_empty → 500) is an availability bug, not exploitable beyond a self-inflicted error — fine to defer, but the eventual fix should land with the failing test first, per usual.

Good work keeping the permission annotations declarative and the dedup enforcement at the constraint layer — that's exactly where they're auditable.

## 🔐 Nora "NullX" Steiner — Application Security Engineer · Ethical Hacker **✅ Approved** I reviewed the full diff plus the surrounding controller, service, CSRF helper, and SecurityConfig on `feat/issue-795-story-documents`. No vulnerability found. What I checked, and why each area passes: ### Blockers None. **Authorization (the headline risk of this PR — guard removal).** The deleted JOURNEY-type check in `JourneyItemService.append()` was a *domain-shape* guard, not a security boundary. Every mutating endpoint that the new panel calls remains declaratively protected: `POST /{id}/items`, `PATCH /{id}/items/{itemId}`, `DELETE /{id}/items/{itemId}`, `PUT /{id}/items/reorder` all carry `@RequirePermission(Permission.BLOG_WRITE)` (`GeschichteController.java:76-104`). Removing the type guard widens what a *BLOG_WRITE* user can do within their own permission class — it grants nothing to anyone below that. No privilege escalation. **IDOR.** `delete()` and `updateNote()` scope the item to the URL's Geschichte via `findByIdAndGeschichteId(itemId, geschichteId)` (`JourneyItemService.java:111, 145`) — an attacker cannot delete item X of story A through story B's URL. `reorder()` validates the requested ID set against `findIdsByGeschichteId` exactly (`JourneyItemService.java:170-173`), so cross-Geschichte item IDs are rejected. Correct pattern. **CSRF.** Both panel mutations go through `csrfFetch` (`StoryDocumentPanel.svelte:62, 99`), which attaches the token for POST/PUT/PATCH/DELETE (`cookies.ts:53-59`). No raw `fetch` for writes. **XSS.** `item.document.title` and the deleted-document placeholder render via Svelte text interpolation only — auto-escaped. No `{@html}`, no `innerHTML` anywhere in the new component. The aria-labels and live-region announcements interpolate titles through Paraglide message functions into text nodes — same story. **Information disclosure in errors.** The reworded "Geschichte not found: {uuid}" messages echo only a Spring-parsed `UUID` path variable — type-constrained, no injection or reflection surface. `failureMessage()` in the panel maps only known codes; unknown codes fall through `getErrorMessage()` whose `default:` returns a generic message (`errors.ts:211-212`) — no raw server detail reaches the DOM. Error-string honesty also improved: the message no longer claims a journey check the code doesn't perform — that matters during incident response. **Audit trail.** Add/remove/note/reorder all log `actorId` after commit (`JourneyItemService.java:102-104, 151-153`). The new STORY write path inherits this for free. **DoS posture.** The 100-item cap and the DB-level dedup index backstop (`uq_journey_items_geschichte_document`, `saveAndFlush` at `JourneyItemService.java:90`) survive the guard removal — the TOCTOU window between the `exists()` pre-check and insert stays closed at the constraint. ### Suggestions 1. **Hardening (smell, not a vuln):** `moveFocusAfterRemove` interpolates `target.id` into a `querySelector` attribute selector (`StoryDocumentPanel.svelte:88`). Today the value is a server-issued UUID, so it's inert — but if this pattern gets copy-pasted somewhere with user-influenced IDs, it becomes a selector-injection foothold. Cheap immunization: `CSS.escape(target.id)`. Detection note: a Semgrep rule matching `querySelector(\`...${...}...\`)` without `CSS.escape` would catch the whole class. 2. **Pre-existing, for awareness:** the authorization model is coarse — any BLOG_WRITE holder can mutate any author's story items. That's the established design of this family-internal archive (consistent with `update`/`delete` on the Geschichte itself), not something this PR introduces or worsens. No action needed here; just flagging that per-author ownership, if ever wanted, belongs in a dedicated issue. 3. The follow-up you noted (note-less item: `ON DELETE SET NULL` vs `chk_journey_item_not_empty` → 500) is an availability bug, not exploitable beyond a self-inflicted error — fine to defer, but the eventual fix should land with the failing test first, per usual. Good work keeping the permission annotations declarative and the dedup enforcement at the constraint layer — that's exactly where they're auditable.
Author
Owner

📋 Elicit — Senior Requirements Engineer / Business Analyst

Approved — every acceptance criterion of #795 is implemented and test-covered; both documented deviations are justified and traceable; no undeclared scope creep.

Acceptance criteria traceability (issue #795 → implementation → test)

# Criterion Implemented Test evidence
1 STORY edit shows document panel in sidebar GeschichteEditor.svelte:237–242GeschichteSidebar.svelte:83–85 GeschichteEditor.svelte.spec.ts "shows the document panel…"
2 No panel on /geschichten/new; attach after first save geschichteId optional, panel gated on it StoryCreate.svelte.spec.ts + GeschichteEditor "hides the document panel"
3 Add creates journey_item, reactive list StoryDocumentPanel.svelte handleAdd (pessimistic POST) Panel spec "POSTs to the items endpoint…"; JourneyItemServiceTest.append_to_STORY_type_creates_journey_item; JourneyItemIntegrationTest.story_type_can_hold_journey_items_end_to_end
4 Remove deletes record, reactive list handleRemove (optimistic DELETE) Panel spec "DELETEs the item endpoint…"
5 Failed remove → item stays + error shown snapshot-and-rollback const prev = [...items] Panel spec "restores the row and shows an error" (500) + catch path for network errors
6 Duplicate unselectable + story-worded 409 fallback alreadyAddedIds derived; failureMessage() Panel specs "marks an already-linked document…" + 409 duplicate test
7 Capacity 409 story-worded geschichte_documents_capacity Panel spec 409 capacity test; backend append_to_STORY_type_respects_capacity_cap
8 Deleted document → removable placeholder placeholder row with remove button, text-ink-3 italic Panel spec deleted-doc test; JourneyItemIntegrationTest.story_item_with_deleted_document_survives_and_remains_deletable
9 Meaningful empty state full sentence + how-to hint Panel spec empty-state test
10 Polite live region + focus never drops to <body> liveAnnounce + moveFocusAfterRemove 2 announce tests + 2 focus tests
11 Position ASC = insertion order defensive sort on init; backend ORDER BY position untouched Panel spec sort test; integration test asserts 10/20/30 order
12 JOURNEYs unaffected JourneyEditor.svelte:355 passes no geschichteId → panel structurally cannot render; JourneyAddBar untouched (verified on branch) GeschichteSidebar.svelte.spec.ts "does not render the panel without geschichteId"
13 V72-migrated items appear and are removable Integration test seeds position-gap rows (10/20/30) via repository, asserts order + removal
14 i18n keys de/en/es 11 new keys × 3 locales present in all three diffs
15 GESCHICHTE_TYPE_MISMATCH fully removed; GESCHICHTE_TYPE_IMMUTABLE stays ErrorCode.java, errors.ts union + case, i18n ×3 I independently grepped the branch: zero mentions remain; GESCHICHTE_TYPE_IMMUTABLE intact

Deviations — assessed as justified

  1. Prop derivation in GeschichteEditor instead of route-level threading. The issue's prohibition rested on a stale-schema premise. I verified the premise is indeed outdated: GeschichteEditor.svelte:15 already types its prop as GeschichteView | null, which carries id and items. The deviation honors the issue's intent (don't read fields the type doesn't have) while the letter no longer applied. Confirmed with the product owner per the completion comment. ✔
  2. Focus target on list-empty = picker input, not panel heading. The heading is hidden below sm — focusing it on mobile would reproduce the exact body-drop failure the requirement guards against. The deviation better satisfies the requirement's underlying need. ✔

Scope check

  • i18n commit collapses three pre-existing exact-duplicate keys per locale — declared in the PR body, zero behavior change. Acceptable hygiene, not creep.
  • README + C4 diagram updates keep docs traceable to the new component. In scope.
  • No gold plating found: no reorder UI, no note field, no drag handles — exactly as the issue constrains.

Blockers

None.

Suggestions

  1. AC 5 names "403" explicitly, but no test mocks a 403. The failure path is exercised via 500 and the network-error catch, and the code path is identical (!res.okfailureMessage()getErrorMessage(code)), so coverage is substantively there — but a one-line 403 variant in StoryDocumentPanel.svelte.spec.ts would close the literal gap and guard the mid-session-revocation scenario the issue calls out.
  2. OQ register item: the ON DELETE SET NULL × chk_journey_item_not_empty collision (note-less linked document → DB-level 500 on document delete) is flagged in the completion comment but not yet tracked. Please open the follow-up Gitea issue before this knowledge evaporates — it directly affects criterion 8's real-world reachability: today the deleted-document placeholder can only ever arise for items that carry a note.
  3. AC 12 is guarded indirectly (sidebar default-props contract + untouched JourneyEditor). That is adequate; if JourneyEditor's sidebar call site ever gains props, nothing fails loudly. A cheap assertion in an existing JourneyEditor spec that geschichte_documents_heading is absent would make the STORY-only constraint regression-proof. Optional.

Fine work — the issue's spec density paid off: 15/15 criteria verifiably met, both deviations argued from the requirement's intent rather than convenience.

## 📋 Elicit — Senior Requirements Engineer / Business Analyst **✅ Approved** — every acceptance criterion of #795 is implemented and test-covered; both documented deviations are justified and traceable; no undeclared scope creep. ### Acceptance criteria traceability (issue #795 → implementation → test) | # | Criterion | Implemented | Test evidence | |---|---|---|---| | 1 | STORY edit shows document panel in sidebar | `GeschichteEditor.svelte:237–242` → `GeschichteSidebar.svelte:83–85` | `GeschichteEditor.svelte.spec.ts` "shows the document panel…" | | 2 | No panel on `/geschichten/new`; attach after first save | `geschichteId` optional, panel gated on it | `StoryCreate.svelte.spec.ts` + `GeschichteEditor` "hides the document panel" | | 3 | Add creates `journey_item`, reactive list | `StoryDocumentPanel.svelte` `handleAdd` (pessimistic POST) | Panel spec "POSTs to the items endpoint…"; `JourneyItemServiceTest.append_to_STORY_type_creates_journey_item`; `JourneyItemIntegrationTest.story_type_can_hold_journey_items_end_to_end` | | 4 | Remove deletes record, reactive list | `handleRemove` (optimistic DELETE) | Panel spec "DELETEs the item endpoint…" | | 5 | Failed remove → item stays + error shown | snapshot-and-rollback `const prev = [...items]` | Panel spec "restores the row and shows an error" (500) + catch path for network errors | | 6 | Duplicate unselectable + story-worded 409 fallback | `alreadyAddedIds` derived; `failureMessage()` | Panel specs "marks an already-linked document…" + 409 duplicate test | | 7 | Capacity 409 story-worded | `geschichte_documents_capacity` | Panel spec 409 capacity test; backend `append_to_STORY_type_respects_capacity_cap` | | 8 | Deleted document → removable placeholder | placeholder row with remove button, `text-ink-3 italic` | Panel spec deleted-doc test; `JourneyItemIntegrationTest.story_item_with_deleted_document_survives_and_remains_deletable` | | 9 | Meaningful empty state | full sentence + how-to hint | Panel spec empty-state test | | 10 | Polite live region + focus never drops to `<body>` | `liveAnnounce` + `moveFocusAfterRemove` | 2 announce tests + 2 focus tests | | 11 | Position ASC = insertion order | defensive sort on init; backend `ORDER BY position` untouched | Panel spec sort test; integration test asserts 10/20/30 order | | 12 | JOURNEYs unaffected | `JourneyEditor.svelte:355` passes no `geschichteId` → panel structurally cannot render; `JourneyAddBar` untouched (verified on branch) | `GeschichteSidebar.svelte.spec.ts` "does not render the panel without geschichteId" | | 13 | V72-migrated items appear and are removable | — | Integration test seeds position-gap rows (10/20/30) via repository, asserts order + removal | | 14 | i18n keys de/en/es | 11 new keys × 3 locales | present in all three diffs | | 15 | `GESCHICHTE_TYPE_MISMATCH` fully removed; `GESCHICHTE_TYPE_IMMUTABLE` stays | ErrorCode.java, errors.ts union + case, i18n ×3 | I independently grepped the branch: zero mentions remain; `GESCHICHTE_TYPE_IMMUTABLE` intact | ### Deviations — assessed as justified 1. **Prop derivation in `GeschichteEditor` instead of route-level threading.** The issue's prohibition rested on a stale-schema premise. I verified the premise is indeed outdated: `GeschichteEditor.svelte:15` already types its prop as `GeschichteView | null`, which carries `id` and `items`. The deviation honors the issue's *intent* (don't read fields the type doesn't have) while the letter no longer applied. Confirmed with the product owner per the completion comment. ✔ 2. **Focus target on list-empty = picker input, not panel heading.** The heading is `hidden` below `sm` — focusing it on mobile would reproduce the exact body-drop failure the requirement guards against. The deviation better satisfies the requirement's underlying need. ✔ ### Scope check - i18n commit collapses three pre-existing exact-duplicate keys per locale — declared in the PR body, zero behavior change. Acceptable hygiene, not creep. - README + C4 diagram updates keep docs traceable to the new component. In scope. - No gold plating found: no reorder UI, no note field, no drag handles — exactly as the issue constrains. ### Blockers None. ### Suggestions 1. **AC 5 names "403" explicitly, but no test mocks a 403.** The failure path is exercised via 500 and the network-error catch, and the code path is identical (`!res.ok` → `failureMessage()` → `getErrorMessage(code)`), so coverage is substantively there — but a one-line 403 variant in `StoryDocumentPanel.svelte.spec.ts` would close the literal gap and guard the mid-session-revocation scenario the issue calls out. 2. **OQ register item: the `ON DELETE SET NULL` × `chk_journey_item_not_empty` collision** (note-less linked document → DB-level 500 on document delete) is flagged in the completion comment but not yet tracked. Please open the follow-up Gitea issue before this knowledge evaporates — it directly affects criterion 8's real-world reachability: today the deleted-document placeholder can only ever arise for items that carry a note. 3. **AC 12 is guarded indirectly** (sidebar default-props contract + untouched `JourneyEditor`). That is adequate; if `JourneyEditor`'s sidebar call site ever gains props, nothing fails loudly. A cheap assertion in an existing `JourneyEditor` spec that `geschichte_documents_heading` is absent would make the STORY-only constraint regression-proof. Optional. Fine work — the issue's spec density paid off: 15/15 criteria verifiably met, both deviations argued from the requirement's intent rather than convenience.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

⚠️ Approved with concerns — every acceptance criterion in #795 has a test behind it and the layering is right (unit caps/dedup with Mockito, real-Postgres Testcontainers for the V72/dangling-document states, browser-mode specs for everything user-visible). My concerns are all about error-path coverage gaps and timing-based flake risk in the new browser specs — none blocks the merge.

What I verified

  • True red/green on the guard deletion: the three new STORY unit tests (JourneyItemServiceTest.java:280–333) would have failed with GESCHICHTE_TYPE_MISMATCH before the guard came out; the two now-untestable rejection tests are deleted, and the verifyNoInteractions import is swept (:42). The story() factory sibling (:743) follows the issue's instruction instead of inlining builders. ✔
  • Integration coverage matches the test plan exactly (JourneyItemIntegrationTest.java:289–376): STORY append+retrieve against real Postgres, V72-style position-gap rows (10/20/30) written through the repository to mirror migrated data with order + removability asserted, and the ON DELETE SET NULL dangling-document case — including the well-commented note workaround for chk_journey_item_not_empty and the follow-up issue flag for the note-less collision. That last find is exactly what integration tests against real constraints are for; an H2 suite would never have surfaced it. ✔
  • StoryDocumentPanel.svelte.spec.ts (14 tests) covers all plan items: position sorting, empty state, deleted-document placeholder + removability, POST payload/URL assertion, dedup-disabled option, both story-worded 409s, DELETE rollback-on-failure, both live-region announcements, and both focus-management cases (previous row / picker input). Test names read as sentences throughout. ✔
  • Optional-props contract is guarded twice independently (GeschichteSidebar.svelte.spec.ts, StoryCreate.svelte.spec.ts) plus the editor-level pass-through spec — the "no panel on /geschichten/new" criterion can't regress silently. ✔
  • Picker change is regression-guarded both ways (DocumentPickerDropdown.svelte.spec.ts:245–261): generated default id + aria-label fallback (protects the untouched JourneyAddBar), and external-id-wins. ✔
  • GESCHICHTE_TYPE_MISMATCH removal is complete across ErrorCode.java, errors.ts (union + case), and all three locale files; the 11 new i18n keys exist in de/en/es; GESCHICHTE_TYPE_IMMUTABLE untouched. ✔

Blockers

None.

Suggestions

  1. The getErrorMessage(code) branch of failureMessage() is never exercised (StoryDocumentPanel.svelte:740 in the diff / component line ~46). The specs cover the two panel-local 409s and the no-code fallback (500 with {} body), but the AC explicitly names 403 ("including 403 for users without BLOG_WRITE — no silently unresponsive buttons") and no test feeds a coded non-409 failure (e.g. { code: 'FORBIDDEN' }, 403) through either mutation. One add-path test asserting the translated message for a 403 would close the last uncovered branch in the panel's error routing.
  2. The catch paths (network failure: fetch rejects) are untested in both handleAdd and handleRemove — the AC says "network error, 403". The remove catch contains its own rollback (items = prev) that is a separate branch from the !res.ok rollback you do test. stubFetch already centralizes the mock; a mutation: 'reject' mode plus one test (vi.fn().mockRejectedValue(...) on non-GET) would cover it.
  3. Flake risk — fixed 350 ms sleep vs. 300 ms debounce (StoryDocumentPanel.svelte.spec.ts:7, waitForDebounce). I know this mirrors the pre-existing PersonTypeahead.svelte.spec.ts:6 idiom, but a 50 ms margin on a loaded CI worker is exactly the kind of timing assumption I'd rather not propagate into new files. Prefer a retrying locator wait (await expect.element(page.getByText(...)).toBeInTheDocument() before clicking — vitest browser locators auto-retry) over the timer; the sleep then becomes unnecessary. Same class of risk, smaller: the live-region assertions read textContent synchronously while announce() auto-clears after 500 ms (StoryDocumentPanel.svelte, announce()); the click→assert gap is normally far below that, but a GC/screenshot pause in CI can eat 500 ms. A retrying await expect.element(...) against the live region, or a longer clear timeout, removes the race.
  4. The "via csrfFetch" plan item is asserted only indirectlystubFetch checks method/URL/body but not that the CSRF header actually rides along. One expect(post?.[1]?.headers) assertion on the X-XSRF-TOKEN header would make the regression (someone swapping csrfFetch for plain fetch, which the backend rejects) fail in the suite instead of in production.
  5. Naming nit: story_type_can_hold_journey_items_end_to_end (JourneyItemIntegrationTest.java:290) goes through the service layer, not HTTP — "end_to_end" oversells it slightly. …_through_the_service_layer or just dropping the suffix keeps the name honest; the existing class style is service-level, so behavior is fine.

Good, disciplined test work overall — the V72 seeding through the repository (not the service) and the dangling-document constraint discovery are exactly the right instincts.

## 🧪 Sara Holt — QA Engineer & Test Strategist **⚠️ Approved with concerns** — every acceptance criterion in #795 has a test behind it and the layering is right (unit caps/dedup with Mockito, real-Postgres Testcontainers for the V72/dangling-document states, browser-mode specs for everything user-visible). My concerns are all about error-path coverage gaps and timing-based flake risk in the new browser specs — none blocks the merge. ### What I verified - **True red/green on the guard deletion**: the three new STORY unit tests (`JourneyItemServiceTest.java:280–333`) would have failed with `GESCHICHTE_TYPE_MISMATCH` before the guard came out; the two now-untestable rejection tests are deleted, and the `verifyNoInteractions` import is swept (`:42`). The `story()` factory sibling (`:743`) follows the issue's instruction instead of inlining builders. ✔ - **Integration coverage matches the test plan exactly** (`JourneyItemIntegrationTest.java:289–376`): STORY append+retrieve against real Postgres, V72-style position-gap rows (10/20/30) written through the repository to mirror migrated data with order + removability asserted, and the `ON DELETE SET NULL` dangling-document case — including the well-commented note workaround for `chk_journey_item_not_empty` and the follow-up issue flag for the note-less collision. That last find is exactly what integration tests against real constraints are for; an H2 suite would never have surfaced it. ✔ - **`StoryDocumentPanel.svelte.spec.ts` (14 tests)** covers all plan items: position sorting, empty state, deleted-document placeholder + removability, POST payload/URL assertion, dedup-disabled option, both story-worded 409s, DELETE rollback-on-failure, both live-region announcements, and both focus-management cases (previous row / picker input). Test names read as sentences throughout. ✔ - **Optional-props contract is guarded twice independently** (`GeschichteSidebar.svelte.spec.ts`, `StoryCreate.svelte.spec.ts`) plus the editor-level pass-through spec — the "no panel on /geschichten/new" criterion can't regress silently. ✔ - **Picker change is regression-guarded both ways** (`DocumentPickerDropdown.svelte.spec.ts:245–261`): generated default id + `aria-label` fallback (protects the untouched `JourneyAddBar`), and external-id-wins. ✔ - `GESCHICHTE_TYPE_MISMATCH` removal is complete across `ErrorCode.java`, `errors.ts` (union + case), and all three locale files; the 11 new i18n keys exist in de/en/es; `GESCHICHTE_TYPE_IMMUTABLE` untouched. ✔ ### Blockers None. ### Suggestions 1. **The `getErrorMessage(code)` branch of `failureMessage()` is never exercised** (`StoryDocumentPanel.svelte:740` in the diff / component line ~46). The specs cover the two panel-local 409s and the no-code fallback (500 with `{}` body), but the AC explicitly names **403** ("including 403 for users without BLOG_WRITE — no silently unresponsive buttons") and no test feeds a coded non-409 failure (e.g. `{ code: 'FORBIDDEN' }`, 403) through either mutation. One add-path test asserting the translated message for a 403 would close the last uncovered branch in the panel's error routing. 2. **The `catch` paths (network failure: `fetch` rejects) are untested in both `handleAdd` and `handleRemove`** — the AC says "network error, 403". The remove `catch` contains its own rollback (`items = prev`) that is a separate branch from the `!res.ok` rollback you do test. `stubFetch` already centralizes the mock; a `mutation: 'reject'` mode plus one test (`vi.fn().mockRejectedValue(...)` on non-GET) would cover it. 3. **Flake risk — fixed 350 ms sleep vs. 300 ms debounce** (`StoryDocumentPanel.svelte.spec.ts:7`, `waitForDebounce`). I know this mirrors the pre-existing `PersonTypeahead.svelte.spec.ts:6` idiom, but a 50 ms margin on a loaded CI worker is exactly the kind of timing assumption I'd rather not propagate into new files. Prefer a retrying locator wait (`await expect.element(page.getByText(...)).toBeInTheDocument()` before clicking — vitest browser locators auto-retry) over the timer; the sleep then becomes unnecessary. Same class of risk, smaller: the live-region assertions read `textContent` synchronously while `announce()` auto-clears after 500 ms (`StoryDocumentPanel.svelte`, `announce()`); the click→assert gap is normally far below that, but a GC/screenshot pause in CI can eat 500 ms. A retrying `await expect.element(...)` against the live region, or a longer clear timeout, removes the race. 4. **The "via csrfFetch" plan item is asserted only indirectly** — `stubFetch` checks method/URL/body but not that the CSRF header actually rides along. One `expect(post?.[1]?.headers)` assertion on the `X-XSRF-TOKEN` header would make the regression (someone swapping `csrfFetch` for plain `fetch`, which the backend rejects) fail in the suite instead of in production. 5. **Naming nit**: `story_type_can_hold_journey_items_end_to_end` (`JourneyItemIntegrationTest.java:290`) goes through the service layer, not HTTP — "end_to_end" oversells it slightly. `…_through_the_service_layer` or just dropping the suffix keeps the name honest; the existing class style is service-level, so behavior is fine. Good, disciplined test work overall — the V72 seeding through the repository (not the service) and the dangling-document constraint discovery are exactly the right instincts.
marcel added 9 commits 2026-06-11 13:17:29 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(geschichte): rename journey items integration test — drop misleading end_to_end suffix (#795)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 6m9s
CI / OCR Service Tests (pull_request) Successful in 28s
CI / Backend Unit Tests (pull_request) Successful in 5m28s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
7ca6492fc0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Review concerns addressed — 9 commits pushed

All blockers and non-blocking suggestions from the review round have been resolved.

Blockers (Markus) —

l3-backend-3g-supporting.pumlf43df608

  • Component description: removed "Enforces JOURNEY-type guard on append." → "Serves both STORY and JOURNEY subtypes."
  • Rel label: "Checks Geschichte existence and type" → "Checks Geschichte existence"

docs/GLOSSARY.md + docs/ARCHITECTURE.md6b2dd2f2

  • JourneyItem entry: no longer claims JOURNEY-only; explains both subtypes share the table; links ADR-037.
  • Geschichte STORY entry: mentions document attachment via journey_items.
  • ARCHITECTURE.md:41: "ordered stops in a JOURNEY-type Geschichte" → "document attachments / editorial notes shared by both subtypes — no application-level type guard"

Architectural decision record (Markus suggestion 1+2) —

docs/adr/037-journey-items-serve-both-geschichte-subtypes.md05652a18

  • Context: V72 dropped geschichten_documents; guard was the only STORY restriction.
  • Decision: one shared table, no type guard; allowlist-over-two-constant-enum is unreachable dead code.
  • Naming asymmetry (journey-flavored 409 codes + story-worded UI messages) documented as intentional.
  • Alternatives rejected: separate story_documents table, allowlist guard, per-subtype validation.

i18n cleanup (Felix suggestion 1) —

chore(i18n): delete dead keys ...8e4810d5

  • geschichte_editor_dokumente_heading / geschichte_editor_dokumente_hint removed from de/en/es.
  • Confirmed zero usages in frontend/src/ before deletion.

Focus-after-rollback (Leonie suggestion 1) —

fix(geschichte): restore focus to item remove button after failed DELETE rollbackd91bedba

  • Two failing tests written first (red): !res.ok rollback branch and network-error catch branch.
  • handleRemove: after items = prev, await tick() + querySelector CSS.escape(item.id) [data-remove-btn] .focus() in both rollback paths.
  • Both tests go green; all 16 existing tests stay green.

Frontend hardening (Nora suggestion 1, Leonie suggestion 2+3, Felix suggestion 4) —

refactor(geschichte): CSS.escape in focus queries, announceTimer cleanup, warning icon23272121

  • CSS.escape(target.id) applied in moveFocusAfterRemove's success-path querySelector (rollback paths already had it from the previous commit).
  • $effect(() => () => { if (announceTimer) clearTimeout(announceTimer); }) — timer cleaned up on component destroy.
  • Warning triangle SVG (aria-hidden="true") added inside <p role="alert">flex items-start gap-2 layout; same heroicons stroke style as the remove button.

Missing test coverage (Sara suggestions 1–4, Elicit suggestion 1) —

test(geschichte): add 403, catch-path, and CSRF header coverage for StoryDocumentPanela6184fa1

  • 403 via handleAdd: routes through getErrorMessage, alert text non-empty and not raw "FORBIDDEN".
  • handleAdd catch (network error): shows journey_mutation_error_reload.
  • handleRemove catch (network error): shows journey_mutation_error_reload.
  • POST carries X-XSRF-TOKEN from cookie: sets document.cookie = 'XSRF-TOKEN=test-csrf-token', asserts headers.get('X-XSRF-TOKEN') === 'test-csrf-token'. Regression-proof against swapping csrfFetch for bare fetch.
  • DELETE carries X-XSRF-TOKEN header: same pattern.
  • 21/21 green.

Debounce flake fix (Sara suggestion 3) —

test(geschichte): replace waitForDebounce sleep with retrying locator waits9ea21f60

  • const waitForDebounce = () => new Promise(...) removed.
  • addViaPicker: await expect.element(page.getByText(title)).toBeInTheDocument() waits for the dropdown result before clicking — auto-retries, no fixed sleep.
  • Duplicate-check test: await expect.element(page.getByRole('option')).toBeInTheDocument() replaces the 350 ms sleep.

Backend test rename (Sara suggestion 5) —

test(geschichte): rename journey items integration test7ca6492f

  • story_type_can_hold_journey_items_end_to_endstory_type_can_hold_journey_items_through_service
  • 59/59 backend tests (JourneyItemServiceTest + JourneyItemIntegrationTest + JourneyItemControllerTest) green.

Follow-up issue (Tobias suggestion 1, Markus suggestion 3, Elicit suggestion 2)

Filed as #805 (bug: deleting a document linked via a note-less journey_item 500s at DB constraint, P1-high) — describes root cause, blast-radius expansion from this PR, reproduction steps, and four candidate fix directions. Assigned before merge as requested.

🤖 Generated with Claude Code

## Review concerns addressed — 9 commits pushed All blockers and non-blocking suggestions from the review round have been resolved. ### Blockers (Markus) — ✅ **`l3-backend-3g-supporting.puml`** — `f43df608` - Component description: removed "Enforces JOURNEY-type guard on append." → "Serves both STORY and JOURNEY subtypes." - Rel label: "Checks Geschichte existence and type" → "Checks Geschichte existence" **`docs/GLOSSARY.md` + `docs/ARCHITECTURE.md`** — `6b2dd2f2` - `JourneyItem` entry: no longer claims JOURNEY-only; explains both subtypes share the table; links ADR-037. - `Geschichte` STORY entry: mentions document attachment via `journey_items`. - `ARCHITECTURE.md:41`: "ordered stops in a JOURNEY-type Geschichte" → "document attachments / editorial notes shared by both subtypes — no application-level type guard" --- ### Architectural decision record (Markus suggestion 1+2) — ✅ **`docs/adr/037-journey-items-serve-both-geschichte-subtypes.md`** — `05652a18` - Context: V72 dropped `geschichten_documents`; guard was the only STORY restriction. - Decision: one shared table, no type guard; allowlist-over-two-constant-enum is unreachable dead code. - Naming asymmetry (journey-flavored 409 codes + story-worded UI messages) documented as intentional. - Alternatives rejected: separate `story_documents` table, allowlist guard, per-subtype validation. --- ### i18n cleanup (Felix suggestion 1) — ✅ **`chore(i18n): delete dead keys ...`** — `8e4810d5` - `geschichte_editor_dokumente_heading` / `geschichte_editor_dokumente_hint` removed from de/en/es. - Confirmed zero usages in `frontend/src/` before deletion. --- ### Focus-after-rollback (Leonie suggestion 1) — ✅ **`fix(geschichte): restore focus to item remove button after failed DELETE rollback`** — `d91bedba` - Two failing tests written first (red): `!res.ok` rollback branch and network-error catch branch. - `handleRemove`: after `items = prev`, `await tick()` + `querySelector CSS.escape(item.id) [data-remove-btn] .focus()` in both rollback paths. - Both tests go green; all 16 existing tests stay green. --- ### Frontend hardening (Nora suggestion 1, Leonie suggestion 2+3, Felix suggestion 4) — ✅ **`refactor(geschichte): CSS.escape in focus queries, announceTimer cleanup, warning icon`** — `23272121` - `CSS.escape(target.id)` applied in `moveFocusAfterRemove`'s success-path querySelector (rollback paths already had it from the previous commit). - `$effect(() => () => { if (announceTimer) clearTimeout(announceTimer); })` — timer cleaned up on component destroy. - Warning triangle SVG (`aria-hidden="true"`) added inside `<p role="alert">` — `flex items-start gap-2` layout; same heroicons stroke style as the remove button. --- ### Missing test coverage (Sara suggestions 1–4, Elicit suggestion 1) — ✅ **`test(geschichte): add 403, catch-path, and CSRF header coverage for StoryDocumentPanel`** — `a6184fa1` - 403 via `handleAdd`: routes through `getErrorMessage`, alert text non-empty and not raw "FORBIDDEN". - `handleAdd` catch (network error): shows `journey_mutation_error_reload`. - `handleRemove` catch (network error): shows `journey_mutation_error_reload`. - POST carries `X-XSRF-TOKEN` from cookie: sets `document.cookie = 'XSRF-TOKEN=test-csrf-token'`, asserts `headers.get('X-XSRF-TOKEN') === 'test-csrf-token'`. Regression-proof against swapping `csrfFetch` for bare `fetch`. - DELETE carries `X-XSRF-TOKEN` header: same pattern. - 21/21 green. --- ### Debounce flake fix (Sara suggestion 3) — ✅ **`test(geschichte): replace waitForDebounce sleep with retrying locator waits`** — `9ea21f60` - `const waitForDebounce = () => new Promise(...)` removed. - `addViaPicker`: `await expect.element(page.getByText(title)).toBeInTheDocument()` waits for the dropdown result before clicking — auto-retries, no fixed sleep. - Duplicate-check test: `await expect.element(page.getByRole('option')).toBeInTheDocument()` replaces the 350 ms sleep. --- ### Backend test rename (Sara suggestion 5) — ✅ **`test(geschichte): rename journey items integration test`** — `7ca6492f` - `story_type_can_hold_journey_items_end_to_end` → `story_type_can_hold_journey_items_through_service` - 59/59 backend tests (JourneyItemServiceTest + JourneyItemIntegrationTest + JourneyItemControllerTest) green. --- ### Follow-up issue (Tobias suggestion 1, Markus suggestion 3, Elicit suggestion 2) Filed as **#805** (`bug: deleting a document linked via a note-less journey_item 500s at DB constraint`, P1-high) — describes root cause, blast-radius expansion from this PR, reproduction steps, and four candidate fix directions. Assigned before merge as requested. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 8 commits 2026-06-11 19:36:07 +02:00
Publishes DocumentDeletingEvent from DocumentService.deleteDocument before
deleteById; JourneyItemDocumentDeleteListener handles it synchronously so
note-less items are gone before ON DELETE SET NULL fires on note-carrying rows.
Plain @EventListener chosen over AFTER_COMMIT (fires too late) and @Async
(breaks rollback atomicity) — see ADR-038. Adds DOCUMENT_DELETED to AuditKind.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(adr): ADR-038 — domain event drives note-less journey-item cleanup on document delete (#805)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m49s
CI / OCR Service Tests (pull_request) Successful in 31s
CI / Backend Unit Tests (pull_request) Failing after 6m43s
CI / fail2ban Regex (pull_request) Successful in 58s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
eefc67bd81
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
deleteDocument now threads the acting user through to the audit log so
DOCUMENT_DELETED records who deleted the document, matching every other
audited write path (storeDocument, updateDocument, applyBulkEditToDocument,
attachFile). Tightens the AC-7 assertion from any() to the concrete actor.

Also adds the missing ApplicationEventPublisher @Mock to DocumentServiceTest,
which the publishEvent call (added with the cascade fix) left null.

Addresses @nora (CWE-778, blocker), @felix and @sara review on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the fully-qualified org.springframework.context.ApplicationEventPublisher
field declaration with an import + simple type name, consistent with every
other field in DocumentService.

Addresses @felix review on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Adds flushAutomatically = true to @Modifying on deleteNoteLessByDocumentId so
the flush-before-bulk-delete contract is explicit instead of relying on
Hibernate AUTO flush-mode behaviour.

Addresses @markus review on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the JourneyItemDocumentDeleteListener component and the
DocumentDeletingEvent edge (documentSvc -> listener) to the supporting-domains
l3 diagram. This is the first event-driven edge in the backend; the diagram
must make that in-process coupling visible per the doc-currency rule.

Addresses @markus review (blocker) on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(geschichte): document FK-load-bearing cleanup order (#805)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m28s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 4m57s
CI / fail2ban Regex (pull_request) Successful in 53s
CI / Semgrep Security Scan (pull_request) Successful in 28s
CI / Compose Bucket Idempotency (pull_request) Successful in 37s
f65296172f
Adds a comment on the @AfterEach deletion sequence explaining that journey_items
must be removed before their referenced documents and geschichten.

Addresses @sara review on PR #806.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
marcel merged commit f65296172f into feat/issue-750-lesereisen-data-model 2026-06-11 19:36:37 +02:00
marcel deleted branch feat/issue-795-story-documents 2026-06-11 19:36:38 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#804