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

Open
opened 2026-06-09 22:08:08 +02:00 by marcel · 0 comments
Owner

Context

V72 migration dropped geschichten_documents and migrated all document links to journey_items. Commit e6c890c6 (on #753) removed the document picker from GeschichteEditor with the note "journey items are managed via the future Lesereisen editor" — intentionally deferred.

As a result, STORY-type Geschichten can no longer have documents added or removed:

  • Frontend: DocumentMultiSelect + documentIds field removed from GeschichteEditor
  • Backend: JourneyItemService.append() has an explicit type guard (lines 47–50) that throws GESCHICHTE_TYPE_MISMATCH for non-JOURNEY types

Documents migrated from before V72 are still readable via journey_items, but the write path is completely closed.

Verified facts (multi-persona review 2026-06-09; re-verified by second clean review 2026-06-09 — all findings folded into this body):

  • GeschichteView.items is populated for both types via GeschichteService.toView()journeyItemService.getItems() — no new endpoint, no schema change, no npm run generate:api run needed (GeschichteView.items confirmed in lib/generated/api.ts:2520).
  • Both write endpoints already exist in GeschichteController (POST /api/geschichten/{id}/items :76, DELETE …/items/{itemId} :94) — all five item endpoints (incl. PATCH note, PUT reorder) carry @RequirePermission(Permission.BLOG_WRITE); the permission surface does not change. IDOR posture unaffected (findByIdAndGeschichteId → cross-journey IDs 404). The edit route already fails closed (+page.server.ts redirects users without canBlogWrite); in-panel 403 handling is defense-in-depth for mid-session revocation/expiry.
  • Item ordering is guaranteed by the repository query (ORDER BY ji.position ASC, JourneyItemRepository:43). "Position ASC" means insertion order from the user's perspective (new documents append at the end) — this is intended; do not "improve" to alphabetical.
  • The type branch already exists at route level: routes/geschichten/[id]/edit/+page.svelte renders JourneyEditor for JOURNEY and GeschichteEditor otherwise — GeschichteEditor is structurally STORY-only. Do not add a second type conditional inside GeschichteEditor/GeschichteSidebar.
  • Audit logging comes for free: append()/delete() already emit JOURNEY_ITEM_ADDED/JOURNEY_ITEM_REMOVED after commit. AuditKind names stay journey-flavored — acceptable, values are persisted in audit rows. Same for the JOURNEY_AT_CAPACITY/JOURNEY_DOCUMENT_ALREADY_ADDED ErrorCode names now firing for stories — the client-side wording split (below) handles everything user-visible.
  • The generated Geschichte entity schema is stale (lib/generated/api.ts:2029–2045): it still lists documents?: Document[] and has no type and no items field. GeschichteEditor types its prop as this stale Geschichte (GeschichteEditor.svelte:10) while the edit route actually passes a GeschichteView. Consequence: reading geschichte.items inside GeschichteEditor will not typecheck — thread explicit props instead (see Frontend). Leave the stale schema untouched in this issue (regen would drag unrelated diff); hygiene follow-up issue is out of scope here.
  • GESCHICHTE_TYPE_MISMATCH has zero mentions in any markdown file (repo-wide grep) — the CLAUDE.md cleanup is a verification step, not an edit step. The adjacent GESCHICHTE_TYPE_IMMUTABLE is a different, live error code and must stay.
  • GESCHICHTE_TYPE_MISMATCH's only test usages are the two tests slated for deletion — no backend/api_tests/*.http references; the REST Client suite needs no update.
  • No Flyway migration, no new env vars, no CI changes (Testcontainers harness reused), i18n compiles via the existing Paraglide Vite plugin.

What needs to be done

Backend — delete the type guard

Decision (2026-06-09): the guard is deleted entirely, not replaced by the originally proposed allowlist. GeschichteType has exactly two constants (STORY, JOURNEY), so an allowlist guard would be unreachable dead code — it can never fire and can never be tested (a nonexistent enum constant cannot be mocked), leaving a permanently uncovered branch under the 88% JaCoCo branch gate. A future third type inherits item support by default; its author decides then.

  1. Remove the type guard in JourneyItemService.append() (lines 47–50). The capacity check (100 items), dedup guard, and note-length validation remain unchanged.
  2. In the same commit: delete the now-unused GeschichteType import (JourneyItemService.java:14), and fix the now-misleading not-found message at :45 — "Journey not found" → "Geschichte not found" (the method serves both types after this change; same for reorder()'s message at :148). Error strings must not claim a check the code no longer performs.
  3. reorder() and updateNote() remain open at the backend for STORY type — the UI simply will not expose them. No additional type guards.
  4. Clean up the now-unused GESCHICHTE_TYPE_MISMATCH (this guard was its only usage in main code):
    • Remove from ErrorCode.java (~line 134)
    • Remove the 'GESCHICHTE_TYPE_MISMATCH' union member and the getErrorMessage() case from frontend/src/lib/shared/errors.ts (:55, :187)
    • Remove error_geschichte_type_mismatch from messages/{de,en,es}.json (:1029 each)
    • Verify no GESCHICHTE_TYPE_MISMATCH mentions remain in markdown docs (there are none today — do not touch GESCHICHTE_TYPE_IMMUTABLE)

Frontend

Add a new StoryDocumentPanel.svelte component to GeschichteSidebar. It lives in lib/geschichte/ next to its .svelte.spec.ts, matching the rest of the domain.

Reuse DocumentPickerDropdown.svelte (used by JourneyAddBar) — it already wraps createDocumentTypeahead() (which lives in frontend/src/lib/document/documentTypeahead.ts) with combobox semantics, loading/empty dropdown states, and alreadyAddedIds dedup-disable. Do not re-wire the raw typeahead hook by hand, and do not reuse DocumentMultiSelect (form-submission-based, hidden inputs — cannot make reactive API calls).

The panel:

  • Holds $state<JourneyItemView[]> locally, defensively sorted by position on init (mirror JourneyEditor.svelte:36); updates reactively on add/remove
  • Add: POST /api/geschichten/{id}/items — pessimistic (append the server response). Remove: DELETE /api/geschichten/{id}/items/{itemId} — optimistic with snapshot-and-rollback (const prev = [...items]), mirroring JourneyEditor
  • All calls via csrfFetch from $lib/shared/cookies.ts (same as JourneyEditor) — a plain fetch POST/DELETE will be rejected
  • Derives alreadyAddedIds from local items (same as JourneyEditor:56) and passes it to the picker so already-linked documents are unselectable; still handles the 409 JOURNEY_DOCUMENT_ALREADY_ADDED for the second-tab/race case
  • Renders document-less items (linked document deleted → ON DELETE SET NULL, V72:44document: null) as a visible placeholder row with its remove button — e.g. italic "Dokument wurde gelöscht" in text-ink-3. Do not hide them: they count toward the 100-item cap and are excluded from alreadyAddedIds — hiding makes the capacity error inexplicable and unfixable from the UI. (For journeys, JourneyItemRow already treats document: null as an interlude, JourneyItemRow.svelte:19 — unaffected.)
  • Shows an empty state when items.length === 0 — one full sentence plus a how-to hint in font-sans text-xs text-ink-3, not just "Keine Dokumente"
  • Surfaces every non-ok response as a translated error (including 403 for users without BLOG_WRITE — no silently unresponsive buttons), routed through getErrorMessage() with a generic fallback — except JOURNEY_AT_CAPACITY and JOURNEY_DOCUMENT_ALREADY_ADDED, which get panel-local story-worded messages (new keys, e.g. geschichte_documents_capacity, geschichte_documents_duplicate): the generic messages say "Lesereise"/"reading journey" in all three locales — the wrong frame inside a STORY panel. Journey wording stays for journeys.
  • Mirrors JourneyEditor's polite live region (liveAnnounce, JourneyEditor.svelte:41): announce "added: {title}" / "removed: {title}" on mutations — the per-button aria-label covers finding the button, not confirming the action worked
  • Manages focus on remove: when the removed row's button leaves the DOM, move focus to the previous row's remove button (or the panel heading when the list empties) — otherwise focus drops to <body> and a keyboard user is teleported to page top
  • No drag handles, no note field, no reorder UI

Props: thread items: JourneyItemView[] and geschichteId: string as explicit optional props the whole way: edit route → GeschichteEditorGeschichteSidebar → panel (defaults: items = []). Do not read geschichte.items inside GeschichteEditor — its prop is typed with the stale Geschichte schema, which has no items field (see Verified facts). GeschichteEditor.svelte:230 currently passes only status + selectedPersons to the sidebar; extend that call site. Both props optional, because GeschichteEditor is also mounted by routes/geschichten/new/StoryCreate.svelte (new story — no ID, no items yet). On /geschichten/new the panel is not rendered; documents can be attached after the first save (same create-then-edit pattern as journeys). Do not pass the full geschichte object. In the edit route both values come from data.geschichte (no additional load).

UX constraints:

  • Follow the existing GeschichteSidebar section stylerounded border border-line bg-surface p-4 shadow-sm, header font-sans text-xs font-bold tracking-widest text-ink-3 uppercase with mb-1/mb-2 plus a small hint paragraph (GeschichteSidebar.svelte:26,55). Not the p-6/mb-5 main-content card pattern — a p-6 card between two p-4 cards in the same column looks broken.
  • <details open class="sm:contents"> mobile accordion including the min-h-[44px] summary row (same as the persons section)
  • No inner max-h scroll clamp on the item list, even near the 100-item cap — a nested scroll area inside a <details> accordion is a touch-trap on small phones. Let the column grow; the mobile accordion collapse handles length.
  • The typeahead input needs a real <label>, not just a placeholder: extend DocumentPickerDropdown with an optional inputId prop — always render a generated default id (mirror the existing doc-picker-listbox-${uid} scheme), keep the aria-label={placeholder} fallback when no external label is wired, so JourneyAddBar stays byte-for-byte untouched. The panel renders a visible <label for> reusing the existing form-label idiom from GeschichteEditor (font-sans text-xs family) — no new label style in the same column.
  • Override the picker placeholder with a story-appropriate i18n key (the component default is m.journey_add_document())
  • Remove buttons: ≥44px touch target (h-11 min-w-[44px]), with an aria-label including the document title ("Dokument entfernen: {title}") — a list of identical "Entfernen" labels is useless to screen-reader users
  • New i18n keys in messages/{de,en,es}.json: panel header, hint, empty state, picker placeholder/label, deleted-document placeholder, story-worded capacity + duplicate errors

Acceptance criteria

  • Editing a STORY-type Geschichte shows a document panel in the sidebar
  • On story creation (/geschichten/new) the document panel is not shown; documents can be attached after the first save
  • Adding a document via the picker creates a journey_item record; list updates reactively
  • Removing a document deletes the journey_item record; list updates reactively
  • If removing a document fails (network error, 403), the document remains in the list and an error message is shown
  • Already-linked documents cannot be selected again in the picker; if the server nevertheless rejects a duplicate (e.g. a second tab), the story-worded duplicate error is shown
  • Adding a document when 100 are already linked shows the story-worded capacity error
  • If a linked document has been deleted, the panel shall display a placeholder entry that can still be removed
  • Empty panel shows a meaningful empty-state message, not a blank section
  • Add/remove mutations are announced via a polite live region; focus does not drop to <body> after removing an item
  • Documents are displayed in position ASC order = insertion order (API default, backed by JourneyItemRepository:43)
  • JOURNEYs are unaffected — their full JourneyEditor with ordering/notes still renders; the document panel is STORY-only
  • Existing documents migrated by V72 appear in the panel and can be removed
  • All new UI strings have i18n keys in de/en/es
  • GESCHICHTE_TYPE_MISMATCH is fully removed (ErrorCode, errors.ts, i18n keys); verified that no mentions remain in markdown docs (GESCHICHTE_TYPE_IMMUTABLE stays)

Test plan

JourneyItemServiceTest — write red first (all three fail today with GESCHICHTE_TYPE_MISMATCH, go green on guard deletion — true red/green):

  • append_to_STORY_type_creates_journey_item()
  • append_to_STORY_type_respects_capacity_cap() (mock countByGeschichteId)
  • append_to_STORY_type_rejects_duplicate_document()
  • Add a story() factory sibling to the existing journey() helper — do not inline Geschichte.builder() per test (the deleted :221 test inlines it; don't copy that)
  • Delete the two STORY-rejection tests (append_returns409_on_non_JOURNEY_type :221 and append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY :240) — they cannot be retargeted: no third enum value exists to feed the guard. Sweep the test file's imports afterwards (verifyNoInteractions may become unused).

JourneyItemIntegrationTest:

  • story_type_can_hold_journey_items_end_to_end() — append + retrieve against real Postgres (existing Testcontainers harness, no CI changes)
  • Seed a STORY with V72-style items — insert journey_items rows directly via the repository with position gaps (10, 20, 30) to mirror real migrated data — and assert order + removability through the API
  • Delete a linked Document → assert the item survives with document IS NULL and is still deletable through the API (covers the ON DELETE SET NULL dangling state)
  • type_defaults_to_STORY_for_new_geschichten (:155) tests the default type, not item rejection — unaffected, leave as is

JourneyItemConstraintsTest contains zero STORY cases — verified, nothing to update there.

Frontend VitestStoryDocumentPanel.svelte.spec.ts (browser-mode *.svelte.spec.ts naming; run locally with targeted --project=client, never the full sweep):

  • Renders items list and empty state
  • An item with document: null renders the deleted-document placeholder and remains removable
  • Add triggers POST /api/geschichten/{id}/items via csrfFetch
  • Remove triggers the DELETE endpoint; on failure the item stays in the list and an error renders
  • An already-added document is not selectable in the dropdown
  • Mocked 409 JOURNEY_DOCUMENT_ALREADY_ADDED renders the story-worded duplicate error
  • Mocked 409 JOURNEY_AT_CAPACITY renders the story-worded capacity error (never seed 100 items in a browser test)
  • StoryCreate renders without the document panel (guards the optional-props behavior)
  • GeschichteSidebar renders the panel when geschichteId + items are provided, and no panel without them (direct guard on the optional-props contract, independent of StoryCreate)

Commit guidance

  • Commit 1: backend guard deletion + unused import + error-string fixes + GESCHICHTE_TYPE_MISMATCH cleanup + test updates — the one-line semantic change stays trivially auditable and bisectable
  • Frontend work in separate commits — a UI rollback must not drag the guard change with it

Blocked by

Depends on #753 (journey-editor) being merged first, as this builds on its data model.

## Context V72 migration dropped `geschichten_documents` and migrated all document links to `journey_items`. Commit `e6c890c6` (on #753) removed the document picker from `GeschichteEditor` with the note "journey items are managed via the future Lesereisen editor" — intentionally deferred. As a result, STORY-type Geschichten can no longer have documents added or removed: - **Frontend:** `DocumentMultiSelect` + `documentIds` field removed from `GeschichteEditor` - **Backend:** `JourneyItemService.append()` has an explicit type guard (lines 47–50) that throws `GESCHICHTE_TYPE_MISMATCH` for non-JOURNEY types Documents migrated from before V72 are still readable via `journey_items`, but the write path is completely closed. **Verified facts** (multi-persona review 2026-06-09; re-verified by second clean review 2026-06-09 — all findings folded into this body): - `GeschichteView.items` is populated for both types via `GeschichteService.toView()` → `journeyItemService.getItems()` — no new endpoint, no schema change, **no `npm run generate:api` run needed** (`GeschichteView.items` confirmed in `lib/generated/api.ts:2520`). - Both write endpoints already exist in `GeschichteController` (`POST /api/geschichten/{id}/items` :76, `DELETE …/items/{itemId}` :94) — all five item endpoints (incl. PATCH note, PUT reorder) carry `@RequirePermission(Permission.BLOG_WRITE)`; the permission surface does not change. IDOR posture unaffected (`findByIdAndGeschichteId` → cross-journey IDs 404). The edit route already fails closed (`+page.server.ts` redirects users without `canBlogWrite`); in-panel 403 handling is defense-in-depth for mid-session revocation/expiry. - Item ordering is guaranteed by the repository query (`ORDER BY ji.position ASC`, `JourneyItemRepository:43`). "Position ASC" means **insertion order** from the user's perspective (new documents append at the end) — this is intended; do not "improve" to alphabetical. - The type branch **already exists at route level**: `routes/geschichten/[id]/edit/+page.svelte` renders `JourneyEditor` for JOURNEY and `GeschichteEditor` otherwise — `GeschichteEditor` is structurally STORY-only. Do **not** add a second type conditional inside `GeschichteEditor`/`GeschichteSidebar`. - Audit logging comes for free: `append()`/`delete()` already emit `JOURNEY_ITEM_ADDED`/`JOURNEY_ITEM_REMOVED` after commit. `AuditKind` names stay journey-flavored — acceptable, values are persisted in audit rows. Same for the `JOURNEY_AT_CAPACITY`/`JOURNEY_DOCUMENT_ALREADY_ADDED` ErrorCode *names* now firing for stories — the client-side wording split (below) handles everything user-visible. - **The generated `Geschichte` entity schema is stale** (`lib/generated/api.ts:2029–2045`): it still lists `documents?: Document[]` and has **no `type` and no `items` field**. `GeschichteEditor` types its prop as this stale `Geschichte` (`GeschichteEditor.svelte:10`) while the edit route actually passes a `GeschichteView`. Consequence: reading `geschichte.items` inside `GeschichteEditor` will not typecheck — thread explicit props instead (see Frontend). Leave the stale schema untouched in this issue (regen would drag unrelated diff); hygiene follow-up issue is out of scope here. - **`GESCHICHTE_TYPE_MISMATCH` has zero mentions in any markdown file** (repo-wide grep) — the CLAUDE.md cleanup is a *verification* step, not an edit step. The adjacent `GESCHICHTE_TYPE_IMMUTABLE` is a different, live error code and **must stay**. - `GESCHICHTE_TYPE_MISMATCH`'s only test usages are the two tests slated for deletion — no `backend/api_tests/*.http` references; the REST Client suite needs no update. - No Flyway migration, no new env vars, no CI changes (Testcontainers harness reused), i18n compiles via the existing Paraglide Vite plugin. ## What needs to be done ### Backend — delete the type guard **Decision (2026-06-09):** the guard is **deleted entirely**, not replaced by the originally proposed allowlist. `GeschichteType` has exactly two constants (STORY, JOURNEY), so an allowlist guard would be unreachable dead code — it can never fire and can never be tested (a nonexistent enum constant cannot be mocked), leaving a permanently uncovered branch under the 88% JaCoCo branch gate. A future third type inherits item support by default; its author decides then. 1. Remove the type guard in `JourneyItemService.append()` (lines 47–50). The capacity check (100 items), dedup guard, and note-length validation remain unchanged. 2. In the same commit: delete the now-unused `GeschichteType` import (`JourneyItemService.java:14`), and fix the now-misleading not-found message at `:45` — "Journey not found" → "Geschichte not found" (the method serves both types after this change; same for `reorder()`'s message at `:148`). Error strings must not claim a check the code no longer performs. 3. `reorder()` and `updateNote()` remain open at the backend for STORY type — the UI simply will not expose them. No additional type guards. 4. Clean up the now-unused `GESCHICHTE_TYPE_MISMATCH` (this guard was its only usage in main code): - Remove from `ErrorCode.java` (~line 134) - Remove the `'GESCHICHTE_TYPE_MISMATCH'` union member and the `getErrorMessage()` case from `frontend/src/lib/shared/errors.ts` (:55, :187) - Remove `error_geschichte_type_mismatch` from `messages/{de,en,es}.json` (:1029 each) - **Verify** no `GESCHICHTE_TYPE_MISMATCH` mentions remain in markdown docs (there are none today — do not touch `GESCHICHTE_TYPE_IMMUTABLE`) ### Frontend Add a new **`StoryDocumentPanel.svelte`** component to `GeschichteSidebar`. It lives in `lib/geschichte/` next to its `.svelte.spec.ts`, matching the rest of the domain. **Reuse `DocumentPickerDropdown.svelte`** (used by `JourneyAddBar`) — it already wraps `createDocumentTypeahead()` (which lives in `frontend/src/lib/document/documentTypeahead.ts`) with combobox semantics, loading/empty dropdown states, and `alreadyAddedIds` dedup-disable. Do not re-wire the raw typeahead hook by hand, and do not reuse `DocumentMultiSelect` (form-submission-based, hidden inputs — cannot make reactive API calls). The panel: - Holds `$state<JourneyItemView[]>` locally, defensively sorted by `position` on init (mirror `JourneyEditor.svelte:36`); updates reactively on add/remove - Add: `POST /api/geschichten/{id}/items` — pessimistic (append the server response). Remove: `DELETE /api/geschichten/{id}/items/{itemId}` — optimistic with snapshot-and-rollback (`const prev = [...items]`), mirroring `JourneyEditor` - **All calls via `csrfFetch` from `$lib/shared/cookies.ts`** (same as `JourneyEditor`) — a plain `fetch` POST/DELETE will be rejected - Derives `alreadyAddedIds` from local items (same as `JourneyEditor:56`) and passes it to the picker so already-linked documents are unselectable; still handles the 409 `JOURNEY_DOCUMENT_ALREADY_ADDED` for the second-tab/race case - **Renders document-less items** (linked document deleted → `ON DELETE SET NULL`, `V72:44` → `document: null`) **as a visible placeholder row with its remove button** — e.g. italic "Dokument wurde gelöscht" in `text-ink-3`. Do not hide them: they count toward the 100-item cap and are excluded from `alreadyAddedIds` — hiding makes the capacity error inexplicable and unfixable from the UI. (For journeys, `JourneyItemRow` already treats `document: null` as an interlude, `JourneyItemRow.svelte:19` — unaffected.) - Shows an empty state when `items.length === 0` — one full sentence plus a how-to hint in `font-sans text-xs text-ink-3`, not just "Keine Dokumente" - Surfaces every non-ok response as a translated error (including 403 for users without `BLOG_WRITE` — no silently unresponsive buttons), routed through `getErrorMessage()` with a generic fallback — **except** `JOURNEY_AT_CAPACITY` and `JOURNEY_DOCUMENT_ALREADY_ADDED`, which get **panel-local story-worded messages** (new keys, e.g. `geschichte_documents_capacity`, `geschichte_documents_duplicate`): the generic messages say "Lesereise"/"reading journey" in all three locales — the wrong frame inside a STORY panel. Journey wording stays for journeys. - **Mirrors `JourneyEditor`'s polite live region** (`liveAnnounce`, `JourneyEditor.svelte:41`): announce "added: {title}" / "removed: {title}" on mutations — the per-button `aria-label` covers *finding* the button, not *confirming* the action worked - **Manages focus on remove**: when the removed row's button leaves the DOM, move focus to the previous row's remove button (or the panel heading when the list empties) — otherwise focus drops to `<body>` and a keyboard user is teleported to page top - No drag handles, no note field, no reorder UI **Props:** thread `items: JourneyItemView[]` and `geschichteId: string` as **explicit optional props** the whole way: edit route → `GeschichteEditor` → `GeschichteSidebar` → panel (defaults: `items = []`). Do **not** read `geschichte.items` inside `GeschichteEditor` — its prop is typed with the stale `Geschichte` schema, which has no `items` field (see Verified facts). `GeschichteEditor.svelte:230` currently passes only `status` + `selectedPersons` to the sidebar; extend that call site. Both props **optional**, because `GeschichteEditor` is also mounted by `routes/geschichten/new/StoryCreate.svelte` (new story — no ID, no items yet). On `/geschichten/new` the panel is not rendered; documents can be attached after the first save (same create-then-edit pattern as journeys). Do not pass the full `geschichte` object. In the edit route both values come from `data.geschichte` (no additional load). **UX constraints:** - Follow the **existing GeschichteSidebar section style** — `rounded border border-line bg-surface p-4 shadow-sm`, header `font-sans text-xs font-bold tracking-widest text-ink-3 uppercase` with `mb-1`/`mb-2` plus a small hint paragraph (GeschichteSidebar.svelte:26,55). Not the `p-6`/`mb-5` main-content card pattern — a `p-6` card between two `p-4` cards in the same column looks broken. - `<details open class="sm:contents">` mobile accordion including the `min-h-[44px]` summary row (same as the persons section) - **No inner `max-h` scroll clamp** on the item list, even near the 100-item cap — a nested scroll area inside a `<details>` accordion is a touch-trap on small phones. Let the column grow; the mobile accordion collapse handles length. - The typeahead input needs a real `<label>`, not just a placeholder: extend `DocumentPickerDropdown` with an optional `inputId` prop — always render a generated default id (mirror the existing `doc-picker-listbox-${uid}` scheme), keep the `aria-label={placeholder}` fallback when no external label is wired, so `JourneyAddBar` stays byte-for-byte untouched. The panel renders a visible `<label for>` reusing the existing form-label idiom from `GeschichteEditor` (`font-sans text-xs` family) — no new label style in the same column. - Override the picker placeholder with a story-appropriate i18n key (the component default is `m.journey_add_document()`) - Remove buttons: ≥44px touch target (`h-11 min-w-[44px]`), with an `aria-label` **including the document title** ("Dokument entfernen: {title}") — a list of identical "Entfernen" labels is useless to screen-reader users - New i18n keys in `messages/{de,en,es}.json`: panel header, hint, empty state, picker placeholder/label, deleted-document placeholder, story-worded capacity + duplicate errors ## Acceptance criteria - [ ] Editing a STORY-type Geschichte shows a document panel in the sidebar - [ ] On story creation (`/geschichten/new`) the document panel is not shown; documents can be attached after the first save - [ ] Adding a document via the picker creates a `journey_item` record; list updates reactively - [ ] Removing a document deletes the `journey_item` record; list updates reactively - [ ] If removing a document fails (network error, 403), the document remains in the list and an error message is shown - [ ] Already-linked documents cannot be selected again in the picker; if the server nevertheless rejects a duplicate (e.g. a second tab), the story-worded duplicate error is shown - [ ] Adding a document when 100 are already linked shows the story-worded capacity error - [ ] If a linked document has been deleted, the panel shall display a placeholder entry that can still be removed - [ ] Empty panel shows a meaningful empty-state message, not a blank section - [ ] Add/remove mutations are announced via a polite live region; focus does not drop to `<body>` after removing an item - [ ] Documents are displayed in position ASC order = insertion order (API default, backed by `JourneyItemRepository:43`) - [ ] JOURNEYs are unaffected — their full `JourneyEditor` with ordering/notes still renders; the document panel is STORY-only - [ ] Existing documents migrated by V72 appear in the panel and can be removed - [ ] All new UI strings have i18n keys in de/en/es - [ ] `GESCHICHTE_TYPE_MISMATCH` is fully removed (ErrorCode, errors.ts, i18n keys); verified that no mentions remain in markdown docs (`GESCHICHTE_TYPE_IMMUTABLE` stays) ## Test plan **`JourneyItemServiceTest`** — write red first (all three fail today with `GESCHICHTE_TYPE_MISMATCH`, go green on guard deletion — true red/green): - `append_to_STORY_type_creates_journey_item()` - `append_to_STORY_type_respects_capacity_cap()` (mock `countByGeschichteId`) - `append_to_STORY_type_rejects_duplicate_document()` - Add a `story()` factory sibling to the existing `journey()` helper — do not inline `Geschichte.builder()` per test (the deleted `:221` test inlines it; don't copy that) - **Delete** the two STORY-rejection tests (`append_returns409_on_non_JOURNEY_type` :221 and `append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY` :240) — they cannot be retargeted: no third enum value exists to feed the guard. Sweep the test file's imports afterwards (`verifyNoInteractions` may become unused). **`JourneyItemIntegrationTest`**: - `story_type_can_hold_journey_items_end_to_end()` — append + retrieve against real Postgres (existing Testcontainers harness, no CI changes) - Seed a STORY with V72-style items — insert `journey_items` rows directly via the repository with position gaps (10, 20, 30) to mirror real migrated data — and assert order + removability through the API - Delete a linked `Document` → assert the item survives with `document IS NULL` and is still deletable through the API (covers the `ON DELETE SET NULL` dangling state) - `type_defaults_to_STORY_for_new_geschichten` (:155) tests the default type, not item rejection — unaffected, leave as is `JourneyItemConstraintsTest` contains zero STORY cases — verified, nothing to update there. **Frontend Vitest** — `StoryDocumentPanel.svelte.spec.ts` (browser-mode `*.svelte.spec.ts` naming; run locally with targeted `--project=client`, never the full sweep): - Renders items list and empty state - An item with `document: null` renders the deleted-document placeholder and remains removable - Add triggers `POST /api/geschichten/{id}/items` via csrfFetch - Remove triggers the DELETE endpoint; on failure the item stays in the list and an error renders - An already-added document is not selectable in the dropdown - Mocked 409 `JOURNEY_DOCUMENT_ALREADY_ADDED` renders the story-worded duplicate error - Mocked 409 `JOURNEY_AT_CAPACITY` renders the story-worded capacity error (never seed 100 items in a browser test) - `StoryCreate` renders **without** the document panel (guards the optional-props behavior) - `GeschichteSidebar` renders the panel when `geschichteId` + `items` are provided, and **no panel without them** (direct guard on the optional-props contract, independent of `StoryCreate`) ## Commit guidance - Commit 1: backend guard deletion + unused import + error-string fixes + `GESCHICHTE_TYPE_MISMATCH` cleanup + test updates — the one-line semantic change stays trivially auditable and bisectable - Frontend work in separate commits — a UI rollback must not drag the guard change with it ## Blocked by Depends on #753 (journey-editor) being merged first, as this builds on its data model.
marcel added the P1-highfeature labels 2026-06-09 22:08:12 +02:00
Sign in to join this conversation.
No Label P1-high feature
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#795