feat(geschichte): restore document management for STORY-type Geschichten #795
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
V72 migration dropped
geschichten_documentsand migrated all document links tojourney_items. Commite6c890c6(on #753) removed the document picker fromGeschichteEditorwith 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:
DocumentMultiSelect+documentIdsfield removed fromGeschichteEditorJourneyItemService.append()has an explicit type guard (lines 47–50) that throwsGESCHICHTE_TYPE_MISMATCHfor non-JOURNEY typesDocuments 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.itemsis populated for both types viaGeschichteService.toView()→journeyItemService.getItems()— no new endpoint, no schema change, nonpm run generate:apirun needed (GeschichteView.itemsconfirmed inlib/generated/api.ts:2520).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.tsredirects users withoutcanBlogWrite); in-panel 403 handling is defense-in-depth for mid-session revocation/expiry.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.routes/geschichten/[id]/edit/+page.svelterendersJourneyEditorfor JOURNEY andGeschichteEditorotherwise —GeschichteEditoris structurally STORY-only. Do not add a second type conditional insideGeschichteEditor/GeschichteSidebar.append()/delete()already emitJOURNEY_ITEM_ADDED/JOURNEY_ITEM_REMOVEDafter commit.AuditKindnames stay journey-flavored — acceptable, values are persisted in audit rows. Same for theJOURNEY_AT_CAPACITY/JOURNEY_DOCUMENT_ALREADY_ADDEDErrorCode names now firing for stories — the client-side wording split (below) handles everything user-visible.Geschichteentity schema is stale (lib/generated/api.ts:2029–2045): it still listsdocuments?: Document[]and has notypeand noitemsfield.GeschichteEditortypes its prop as this staleGeschichte(GeschichteEditor.svelte:10) while the edit route actually passes aGeschichteView. Consequence: readinggeschichte.itemsinsideGeschichteEditorwill 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_MISMATCHhas zero mentions in any markdown file (repo-wide grep) — the CLAUDE.md cleanup is a verification step, not an edit step. The adjacentGESCHICHTE_TYPE_IMMUTABLEis a different, live error code and must stay.GESCHICHTE_TYPE_MISMATCH's only test usages are the two tests slated for deletion — nobackend/api_tests/*.httpreferences; the REST Client suite needs no update.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.
GeschichteTypehas 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.JourneyItemService.append()(lines 47–50). The capacity check (100 items), dedup guard, and note-length validation remain unchanged.GeschichteTypeimport (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 forreorder()'s message at:148). Error strings must not claim a check the code no longer performs.reorder()andupdateNote()remain open at the backend for STORY type — the UI simply will not expose them. No additional type guards.GESCHICHTE_TYPE_MISMATCH(this guard was its only usage in main code):ErrorCode.java(~line 134)'GESCHICHTE_TYPE_MISMATCH'union member and thegetErrorMessage()case fromfrontend/src/lib/shared/errors.ts(:55, :187)error_geschichte_type_mismatchfrommessages/{de,en,es}.json(:1029 each)GESCHICHTE_TYPE_MISMATCHmentions remain in markdown docs (there are none today — do not touchGESCHICHTE_TYPE_IMMUTABLE)Frontend
Add a new
StoryDocumentPanel.sveltecomponent toGeschichteSidebar. It lives inlib/geschichte/next to its.svelte.spec.ts, matching the rest of the domain.Reuse
DocumentPickerDropdown.svelte(used byJourneyAddBar) — it already wrapscreateDocumentTypeahead()(which lives infrontend/src/lib/document/documentTypeahead.ts) with combobox semantics, loading/empty dropdown states, andalreadyAddedIdsdedup-disable. Do not re-wire the raw typeahead hook by hand, and do not reuseDocumentMultiSelect(form-submission-based, hidden inputs — cannot make reactive API calls).The panel:
$state<JourneyItemView[]>locally, defensively sorted bypositionon init (mirrorJourneyEditor.svelte:36); updates reactively on add/removePOST /api/geschichten/{id}/items— pessimistic (append the server response). Remove:DELETE /api/geschichten/{id}/items/{itemId}— optimistic with snapshot-and-rollback (const prev = [...items]), mirroringJourneyEditorcsrfFetchfrom$lib/shared/cookies.ts(same asJourneyEditor) — a plainfetchPOST/DELETE will be rejectedalreadyAddedIdsfrom local items (same asJourneyEditor:56) and passes it to the picker so already-linked documents are unselectable; still handles the 409JOURNEY_DOCUMENT_ALREADY_ADDEDfor the second-tab/race caseON DELETE SET NULL,V72:44→document: null) as a visible placeholder row with its remove button — e.g. italic "Dokument wurde gelöscht" intext-ink-3. Do not hide them: they count toward the 100-item cap and are excluded fromalreadyAddedIds— hiding makes the capacity error inexplicable and unfixable from the UI. (For journeys,JourneyItemRowalready treatsdocument: nullas an interlude,JourneyItemRow.svelte:19— unaffected.)items.length === 0— one full sentence plus a how-to hint infont-sans text-xs text-ink-3, not just "Keine Dokumente"BLOG_WRITE— no silently unresponsive buttons), routed throughgetErrorMessage()with a generic fallback — exceptJOURNEY_AT_CAPACITYandJOURNEY_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.JourneyEditor's polite live region (liveAnnounce,JourneyEditor.svelte:41): announce "added: {title}" / "removed: {title}" on mutations — the per-buttonaria-labelcovers finding the button, not confirming the action worked<body>and a keyboard user is teleported to page topProps: thread
items: JourneyItemView[]andgeschichteId: stringas explicit optional props the whole way: edit route →GeschichteEditor→GeschichteSidebar→ panel (defaults:items = []). Do not readgeschichte.itemsinsideGeschichteEditor— its prop is typed with the staleGeschichteschema, which has noitemsfield (see Verified facts).GeschichteEditor.svelte:230currently passes onlystatus+selectedPersonsto the sidebar; extend that call site. Both props optional, becauseGeschichteEditoris also mounted byroutes/geschichten/new/StoryCreate.svelte(new story — no ID, no items yet). On/geschichten/newthe panel is not rendered; documents can be attached after the first save (same create-then-edit pattern as journeys). Do not pass the fullgeschichteobject. In the edit route both values come fromdata.geschichte(no additional load).UX constraints:
rounded border border-line bg-surface p-4 shadow-sm, headerfont-sans text-xs font-bold tracking-widest text-ink-3 uppercasewithmb-1/mb-2plus a small hint paragraph (GeschichteSidebar.svelte:26,55). Not thep-6/mb-5main-content card pattern — ap-6card between twop-4cards in the same column looks broken.<details open class="sm:contents">mobile accordion including themin-h-[44px]summary row (same as the persons section)max-hscroll 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.<label>, not just a placeholder: extendDocumentPickerDropdownwith an optionalinputIdprop — always render a generated default id (mirror the existingdoc-picker-listbox-${uid}scheme), keep thearia-label={placeholder}fallback when no external label is wired, soJourneyAddBarstays byte-for-byte untouched. The panel renders a visible<label for>reusing the existing form-label idiom fromGeschichteEditor(font-sans text-xsfamily) — no new label style in the same column.m.journey_add_document())h-11 min-w-[44px]), with anaria-labelincluding the document title ("Dokument entfernen: {title}") — a list of identical "Entfernen" labels is useless to screen-reader usersmessages/{de,en,es}.json: panel header, hint, empty state, picker placeholder/label, deleted-document placeholder, story-worded capacity + duplicate errorsAcceptance criteria
/geschichten/new) the document panel is not shown; documents can be attached after the first savejourney_itemrecord; list updates reactivelyjourney_itemrecord; list updates reactively<body>after removing an itemJourneyItemRepository:43)JourneyEditorwith ordering/notes still renders; the document panel is STORY-onlyGESCHICHTE_TYPE_MISMATCHis fully removed (ErrorCode, errors.ts, i18n keys); verified that no mentions remain in markdown docs (GESCHICHTE_TYPE_IMMUTABLEstays)Test plan
JourneyItemServiceTest— write red first (all three fail today withGESCHICHTE_TYPE_MISMATCH, go green on guard deletion — true red/green):append_to_STORY_type_creates_journey_item()append_to_STORY_type_respects_capacity_cap()(mockcountByGeschichteId)append_to_STORY_type_rejects_duplicate_document()story()factory sibling to the existingjourney()helper — do not inlineGeschichte.builder()per test (the deleted:221test inlines it; don't copy that)append_returns409_on_non_JOURNEY_type:221 andappend_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 (verifyNoInteractionsmay become unused).JourneyItemIntegrationTest:story_type_can_hold_journey_items_end_to_end()— append + retrieve against real Postgres (existing Testcontainers harness, no CI changes)journey_itemsrows directly via the repository with position gaps (10, 20, 30) to mirror real migrated data — and assert order + removability through the APIDocument→ assert the item survives withdocument IS NULLand is still deletable through the API (covers theON DELETE SET NULLdangling state)type_defaults_to_STORY_for_new_geschichten(:155) tests the default type, not item rejection — unaffected, leave as isJourneyItemConstraintsTestcontains zero STORY cases — verified, nothing to update there.Frontend Vitest —
StoryDocumentPanel.svelte.spec.ts(browser-mode*.svelte.spec.tsnaming; run locally with targeted--project=client, never the full sweep):document: nullrenders the deleted-document placeholder and remains removablePOST /api/geschichten/{id}/itemsvia csrfFetchJOURNEY_DOCUMENT_ALREADY_ADDEDrenders the story-worded duplicate errorJOURNEY_AT_CAPACITYrenders the story-worded capacity error (never seed 100 items in a browser test)StoryCreaterenders without the document panel (guards the optional-props behavior)GeschichteSidebarrenders the panel whengeschichteId+itemsare provided, and no panel without them (direct guard on the optional-props contract, independent ofStoryCreate)Commit guidance
GESCHICHTE_TYPE_MISMATCHcleanup + test updates — the one-line semantic change stays trivially auditable and bisectableBlocked by
Depends on #753 (journey-editor) being merged first, as this builds on its data model.