feat(lesereisen): frontend Journey editor — ordered item list, document picker, interlude notes, reorder #753
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?
Goal
Build the curator editing experience for Reading Journeys: an ordered item list with the ability to add letters, add interlude notes, edit notes, remove items, and drag-to-reorder.
Background
Builds on the list/detail frontend issue. The edit page at
/geschichten/[id]/editalready handles Story editing viaGeschichteEditor. This issue adds the parallelJourneyEditorcomponent. Design spec:docs/superpowers/specs/2026-06-06-lesereisen-design.md.Prerequisite: This issue depends on a backend issue that implements
Geschichte.type(GeschichteTypeenum),GeschichteItementity + Flyway migration, and all five item endpoints (POST /items,PATCH /items/{itemId},DELETE /items/{itemId},PUT /items/reorder). Frontend branch must not be merged until that backend PR is merged first.JOURNEY creation seam (REQ-LE-CREATE-001): Today nothing creates a JOURNEY —
new/+page.sveltemountsGeschichteEditorunconditionally and POSTs with notype, so this editor would be unreachable. Type selection at creation is owned by a separate small frontend issue (see Review Insights → Resolved Decisions). Since type is immutable after creation (REQ-LE-TYPE-001), it MUST be chosen at/geschichten/new. Link that issue as blocked-by before merge.Tasks
JourneyEditorcomponent (src/lib/geschichte/JourneyEditor.svelte)Rendered on the edit page when
type === 'JOURNEY'. Contains:POST /api/geschichten/{id}/itemswithdocumentIdPOST /api/geschichten/{id}/itemswithnotePATCH /api/geschichten/{id}/items/{itemId}DELETE /api/geschichten/{id}/items/{itemId}PUT /api/geschichten/{id}/items/reorderGeschichte.body; small textarea above the item listEdit page changes (
/geschichten/[id]/edit)The
{#if geschichte.type === 'JOURNEY'}branch goes inedit/+page.svelte— not insideGeschichteEditor.GeschichteEditorgets zero changes except factoring out the shared sidebar intoGeschichteSidebar.svelte, which both editors import. Do not makeGeschichteEditortype-aware.Requirements (EARS)
documentIdOR a non-emptynote(or both). The system shall reject (HTTP 400) an item with neither. This is the formal definition of a valid "interlude" and drives the "block empty interlude add" UI rule.GeschichteTypeis set once at creation and is immutable thereafter. There is no STORY↔JOURNEY conversion path (a STORY's TipTap body and a JOURNEY's items are incompatible). No "change type" control shall exist, AND the API must reject atypechange on PATCH (not only the UI)./geschichten/newtype-selector issue.<,>, and&. AC: a JOURNEY intro containingTemperatur < 0, when saved and reloaded, shows the full text including< 0.documentId) shall be editable inline via the same note-PATCH mechanism used for document-item notes; the same confirm/blur-save model applies. The implementer MUST NOT make interludes immutable.documentIdnornote, violating REQ-LE-ITEM-001). The "Notiz entfernen" control is ABSENT for note-only interludes; the only way to remove an interlude is "remove item". Editing an interlude to empty is also blocked (REQ-LE-ITEM-001 symmetry at edit time, not only add time).Acceptance criteria
JourneyEditor; whentype === 'JOURNEY', the edit page shows an ordered item list with "Brief hinzufügen" and "Zwischentext hinzufügen" buttons — no TipTap editor elements are visible (testable viarole="toolbar"witharia-label="Formatierung"being absent)type === 'STORY', the TipTap editor is rendered and no JourneyEditor elements are visiblearia-disabled="true"and prevents re-adding them; they appear in search results but clicking them has no effect. The accessible name of a disabled option includes both the title and "bereits enthalten" so screen-reader users hear why it does nothing.noteis set to null via PATCH (control present only on document-items, never on note-only interludes)window.confirm()); removing one without a note does notopacity-60+ anaria-live="polite""wird hinzugefügt…" / "wird entfernt…" status) until the API resolves, so a rollback reads as "the tentative item went away" not "a real item vanished"bodywhen the curator clicks "Speichern" or "Entwurf speichern" (not on blur); the intro carries NO blur hint and instead shows "Wird mit 'Speichern' gesichert."<,>,&on save+reload (REQ-LE-INTRO-001)messages/de.json,messages/en.json, andmessages/es.jsonReview Insights
Round 6 — Final Resolutions (these supersede any contradicting decision below)
Round 6 re-examined six decisions against the live code and found the spec contradicted reality on two of them. All six are now closed:
Intro storage → RAW-at-rest (SUPERSEDES "escape-as-text in the backend at write"). The editor loads
geschichte.bodystraight into a<textarea>($state(geschichte?.body ?? '')). Escaped-at-rest therefore shows the curator the literalTemperatur < 0on reload and double-escapes to&lt;on re-save — failing the editor half of REQ-LE-INTRO-001. Decision: persist the intro raw for JOURNEY. SkipBODY_SANITIZERon the JOURNEY branch (it drops<), store the raw textarea string, and let the reader's existing{@html safeHtml(g.body)}(DOMPurify) escape/sanitize at render — one escaping site, at the reader, not two. The editor textarea isbind:valueonly, never{@html}. Rationale: this is the only configuration where BOTH AC clauses pass (editor read-back shows raw< 0; reader shows rendered< 0). Cost: the DB column holds un-sanitized text for JOURNEY — acceptable only because every render path issafeHtml/{text}(see mandatory test below). (Felix, Nora, Elicit)< 0), NOT HTML entities. This disambiguates "preserve literal characters" (it means byte-preserved at rest AND raw in the editor, not merely render-preserved).journey_body_render_path_is_sanitizedasserting the reader path interpolates throughsafeHtml/{text}, plus a code comment on the JOURNEYbodysemantics. Because the DB now holds raw text, this test is the sole stored-XSS defense — any future "journey intro preview" that uses{@html}withoutsafeHtmlis the regression to guard against.Temperatur < 0(raw); theonSubmitpayload sendsbody: 'Temperatur < 0'raw on the wire; the reader rendersTemperatur < 0.Keyboard reorder → reuse the existing move-up / move-down buttons from
TranscriptionEditView(SUPERSEDES the Space/Arrow/Space keyboard-drag addition tocreateBlockDragDrop).createBlockDragDrophas zero keyboard handling today, so keyboard-drag was net-new ~40 lines + a settle animation + live-region choreography.TranscriptionEditViewalready ships tested, accessiblehandleMoveUp/handleMoveDownbuttons (lines 133–147). Decision: keepcreateBlockDragDrop's pointer-drag for mouse (still generalize it tocreateBlockDragDrop<T extends { id: string }>for the pointer path and preserve thedata-block-wrapper/data-drag-handleselector contract), and provide keyboard accessibility via move-up/down buttons on each row instead of keyboard-on-the-handle. Rationale: simpler, already tested, no new keyboard state machine, noprefers-reduced-motionsettle transition, and arguably clearer for the 60+ audience than a drag affordance. The buttons fire the same optimistic-local-reorder + background-PUT + rollback path as pointer-drag. (Felix)prefers-reduced-motion"settle" requirement is dropped (no keyboard-drag animation). Thejourney_item_movedaria-liveannouncer stays — fire it on a button-move too. The E2E reorder step uses the move buttons (deterministic), not Space/Arrow/Space. Each move button needs amin-h-[44px]/min-w-[44px]hit area and a parameterized aria-label (e.g.journey_move_up/journey_move_downwith{title}). Disable "up" on the first row and "down" on the last.Code-split → static-import both editors (do NOT dynamic-
import()JourneyEditor). Under SSR +adapter-node, a dynamic import inside the{#if type === 'JOURNEY'}branch is a no-op during SSR and renders nothing until the client chunk resolves — a flash-of-empty-editor on the exact page the 60+/slow-phone audience is working in, requiring a bespoke skeleton to paper over. The asymmetry: STORY already pays for TipTap; only JOURNEY would save by skipping it, andJourneyEditor(item list, no TipTap) is the light one. Decision: static-import both; the cost is JOURNEY users downloading TipTap once (cached thereafter), which is far cheaper than a first-paint loading state and an SSR-vs-client code path to maintain. KISS wins. (Tobias) — This supersedes the "Code-split the two editors" decision below.Mobile save bar → non-sticky while a text field is focused (NOT an intro
max-h-[25vh]cap). At 320px with the soft keyboard up, amax-h-[40vh]intro + item list + asticky bottom-0save bar overlap, hiding the item list or floating the bar over the keyboard. Decision: on<sm, toggle the save bar'sstickyoff while an input/textarea inside the editor is focused (a focus/blur listener), so it scrolls away with content during typing and returns sticky on blur. Chosen over capping the intro — a curator focused on a field is typing, not saving, so a non-sticky bar during typing is fine, and a long intro stays comfortable to edit. (Leonie)aria-disabledPublish button moves focus into the now-open Status collapsible (not just scrolls it into view).journey_note_save_hint); intro/title save on Speichern only (journey_intro_save_hint, "Wird mit 'Speichern' gesichert."); interlude add uses an explicit confirm. Each carries its owntext-xs text-ink-2hint, and the interlude-add confirm is a labelled verb button ("Hinzufügen"), not a checkmark icon.Backend read strategy → a dedicated
@Transactional(readOnly = true) getByIdWithItems(id)that delegates togetById(id)first for the DRAFT guard, then runs the items fetch-join (do NOT makegetByIditself@Transactionalin place).getByIdis non-transactional today (line 63). Adding@Transactionalto it would wrap the EAGERpersonsload and theDRAFT && !blogWrite → 404guard in a transaction and change the session lifecycle for every STORY read flowing through it — an acceptable-per-ADR-022 but wider blast radius where a STORY regression could hide inside a JOURNEY PR. The separate method keeps the STORY read path byte-for-byte unchanged at the cost of one delegating method. Record the alternative-and-why in the ADR fetch-strategy section. (Markus) — refines the "JOURNEY item read inheritsgetById's DRAFT guard" decision below toward the second listed option.Silent item removal on document delete → accepted, documented non-goal (no curator notification). The
ON DELETE CASCADEsilently shortens a published journey when a letter is deleted elsewhere. For a family archive where document deletion is rare and admin-driven, silent removal is acceptable; a notification/tombstone would creep into the notification + reader-empty-slot domains. Add this explicit non-goal: "When a document is deleted, its journey item(s) are removed silently; the curator is not notified in-app." The cascade is reconstructable post-hoc iff document deletes are already audit-logged — confirm in the PR that the existing audit log captures document deletes; if so, no new observability hook is needed (do NOT add a journey-specific counter). (Elicit, Tobias)Additional round-6 requirement clarifications:
create()trustsdto.getType()(no existing entity — the/geschichten/newselector sets it); the create-path frozen-join guard isif (dto.getType() == JOURNEY && documentIds present).update()trustsg.getType()and rejects a differingdto.getType(). These read the type from two different sources — spell both out so a reviewer doesn't write one guard for both.typeomitted (null) → 200;typesame asg.type→ 200 (idempotent);typediffering → 409. Test all three — the single 409 test invites a strict-equality bug that over-rejects every normal title-only PATCH. Allowed because a normal PATCH sendstype: null.item_add_on_published_journey_does_not_change_parent_updatedAt, addintro_save_on_published_journey_DOES_update_updatedAt. Two tests pin the real contract (item mutations don't republish; an intro/title Speichern does); one test invites a wrong "freezeupdatedAteverywhere" fix.note_with_script_tag_is_stored_and_rendered_as_text— a note containing<img src=x onerror=alert(1)>renders as literal text in BOTH editor preview and reader (notes are unsanitized plaintext; the only safety is{text}interpolation).opacity-60+ "wird hinzugefügt…") is exempt and required — it is NOT the spinner the NFR forbids.Resolved Decisions
DnD mechanism → reuse the in-house
createBlockDragDropmodule for POINTER drag + generalize; keyboard accessibility via move-up/down buttons (UPDATED round 6 — keyboard-drag dropped, see Round 6 Resolution #2).A production PointerEvent reorder module already ships in the transcription editor (
useBlockDragDrop.svelte.ts, callbackonReorder(ids: string[])). Addingsvelte-dnd-actionor@dnd-kitwould introduce a second DnD system. Decision: reusecreateBlockDragDropfor mouse/touch pointer drag. The reuse is NOT a copy-paste — the module is hard-typed toTranscriptionBlockDataand couples to DOM via literal selectors[data-block-wrapper]/[data-drag-handle](lines 27, 37). Explicit tasks: (1) generalize the signature tocreateBlockDragDrop<T extends { id: string }>(...); (2) keepdata-block-wrapper/data-drag-handleas the documented selector contract and haveJourneyItemRowemit those exact attributes (different attribute names silently no-op the pointer hit-testing); (3) keyboard accessibility is provided by per-row move-up / move-down buttons copied fromTranscriptionEditView(handleMoveUp/handleMoveDown, lines 133–147) — NOT by adding Space/Arrow/Space to the handle (the module has zero keyboard handling today; keyboard-drag was net-new and is dropped); (4)DROPPED with keyboard-drag; (5) add a type-level test that transcription still compiles against the generalized signature so the refactor cannot regress the existing caller. A reviewer must reject a copy-paste fork — that recreates the exact second-DnD-system problem this decision prevents. Do NOT addprefers-reduced-motionsettle transitionsvelte-dnd-actionor@dnd-kit.Type-regression guard runs in the Vitest/
tsc --noEmitgate, NOT svelte-check (DECIDED round 5). CI does NOT gate onnpm run check(~834 pre-existing svelte-check errors). The "transcription still compiles against the generalized signature" guard MUST therefore be a real Vitest type-test (e.g.expectTypeOf/a compile-asserting spec) or atsc --noEmitover the changed files — something that actually fails CI. A svelte-check-only guard would let a transcription-caller type regression ship silently.Reorder API contract → ordered-ID array
{ itemIds: [...] }, server assigns positions (SUPERSEDESposition = index * 10).Matches the existing transcription
{ blockIds: [...] }contract. The reorder endpoint accepts{ itemIds: [...] }in display order; the server is the only writer ofpositionand assigns0..natomically in one transaction. This eliminates client-side gap arithmetic and makes the UNIQUE(geschichte_id, position)constraint trivially satisfiable. Display order on read is derived frompositionASC; the client never computesposition.Reorder UI flow → optimistic local reorder + background PUT with rollback, NO refetch (DECIDED round 5, was open).
On
onReorder, reorder the localitemsarray immediately (synchronous), then fire the PUT in the background; on failure restore the pre-mutation snapshot and show a visible error. Do NOT refetch + re-sort after the PUT. Rationale: matches the optimistic add/remove model already specced; keeps the keyed-{#each}corruption test deterministic and synchronous (no race against a refetch); and the server'spositionassignment stays authoritative only on the next full page load — the transient cross-tab order drift this allows is already an accepted non-goal (last-write-wins). The server remains the only writer ofposition; the client's optimistic array is display-only until reload. Consequence for tests: the keyed-{#each}test reorders the local array synchronously with the PUT merely mocked-to-resolve — its assertion does NOT belong to the "PUT + GET,toHaveBeenCalledTimes(2)" pattern (that count belongs to the rollback test). Reconcile the two test descriptions accordingly.Document search → reuse the existing
useTypeaheadhook (SUPERSEDES "createuseDocumentSearch").$lib/shared/hooks/useTypeahead.svelte.tsexists:createTypeahead<T>({ fetchUrl, onSelect, debounceMs }). Decision: (a) refactorDocumentMultiSelect.svelte(still on a raw pre-useTypeaheadsetTimeoutdebounce, lines ~36–56) ontouseTypeahead, then (b) buildDocumentPickerDropdown(single-select) on the same hook. "Reuse" here means reuse the HOOK, not the component —DocumentMultiSelectowns its ownselectedDocumentschip state which the picker does NOT want (journey items live server-side).Corrected
fetchUrl(round 5 — the prior snippet was broken):DocumentController.searchreturns a paginatedDocumentSearchResultwrapper{ items: [...] }, NOT an array, andcreateTypeahead<T>expectsPromise<T[]>. Use:(q) => fetch('/api/documents/search?q=' + encodeURIComponent(q) + '&size=10').then(r => r.json()).then((b: { items: DocumentListItem[] }) => b.items)Without the
.then(b => b.items)the dropdown renders nothing.useTypeaheadhas a fixed 300ms debounce, always setsisOpen = trueonsetQuery, and has no in-flight cancellation (last-write-wins; request N can resolve after N+1). This is accepted as-is (KISS — a 10-result archive search does not justify anAbortController); thealreadyAddedIdsderivation is$derivedoveritems(not over results), so it stays correct regardless of which response lands last. State this in the PR so a reviewer doesn't flag it as a missing-cancellation bug.Document picker empty-query behavior → keep the
< 1 charguard (Option A, DECIDED round 5, was open).DocumentPickerDropdownguardsquery.trim().length >= 1before callingsetQuery; the dropdown stays empty until the curator types. Chosen over preloading the 10 most-recent on focus: consistent with the existingDocumentMultiSelectbehavior, costs one guard line, and avoids aq=&size=10"latest 10 docs" surprise in what is labelled a search box. Add a unit test:picker does not call fetch on empty query.GeschichteItemfetch strategy → lazy + dedicated@Transactional(readOnly=true)fetch-join (DECIDED).Chosen over a third EAGER collection. The backend implements a
geschichteRepository-levelLEFT JOIN FETCH g.items i ... ORDER BY i.positionquery invoked from a@Transactional(readOnly = true)read method. Rationale: a third EAGER collection would cartesian-join with the existing EAGERpersonsset on every Geschichte GET including STORY reads that never use items, andgetById/listare non-transactional today — a lazy@OneToManyserialized outside a transaction throwsLazyInitializationExceptionin production (ADR-022). The dedicated transactional fetch-join read pays no cost on STORY reads and avoids the lazy-init trap.Item-load query shape → fetch ONLY items in the fetch-join; persons stay EAGER via the entity (DECIDED round 5, was open). The
LEFT JOIN FETCH g.itemsstatement MUST NOT alsoJOIN FETCH g.persons. Fetching two collections (persons × items) in one statement produces a Cartesian row blow-up (and wouldMultipleBagFetchExceptionif either were aList; they areSets so it merely multiplies rows). Fetch one collection per query: items via the dedicated transactional fetch-join, persons via the entity's existing EAGER load — two SELECTs, no row multiplication, negligible at family-archive cardinality. Because the "documents frozen for JOURNEY" decision meansdocumentsis empty for journeys, only persons×items was the risk. Record this in the ADR fetch-strategy section so a future dev doesn't "optimize" into a Cartesian fetch.JOURNEY item read inherits
getById's DRAFT guard — no guard-free read path (DECIDED round 5; read-strategy settled round 6). Items must be exposed only as a FIELD on the existing, already-guardedGET /api/geschichten/{id}(andlist). Round 6 picks the second option: a dedicated@Transactional(readOnly = true) getByIdWithItems(id)that callsgetById(id)FIRST for theDRAFT && !blogWrite → 404guard, then runs the items fetch-join — leaving the STORY read path byte-for-byte unchanged (see Round 6 Resolution #5). Do NOT makegetByIditself@Transactionalin place (wider STORY blast radius). Do NOT add a separateGET /api/geschichten/{id}/itemsendpoint and do NOT add afindByIdWithItemsrepository call that bypasses the DRAFT check — either would leak a DRAFT journey's items to anonymous readers. Theget_journey_returns_items_in_position_order@SpringBootTestasserts againstgetByIdWithItems's path.JOURNEY
create()ANDupdate()both reject/ignoredocumentIdsfor the frozen join table (DECIDED round 5 —createwas the missed path). The "documents frozen for JOURNEY" rule is not update-only.create()(the JOURNEY-creation path via the/geschichten/newselector) currently buildsdocuments(resolveDocuments(dto.getDocumentIds()))unconditionally — for a JOURNEY this would populate the frozengeschichten_documentstable. Guard BOTH paths:if (type == JOURNEY && documentIds present) ignore-or-reject. This is the FOURTH ADR point (see ADR task).JOURNEY intro encoding on write → store RAW for JOURNEY, escape only at the reader (UPDATED round 6 — SUPERSEDES "escape-as-text in the backend at write"; see Round 6 Resolution #1).
GeschichteService.sanitize()runsbodythroughBODY_SANITIZER(OWASPHtmlPolicyBuilder), which treats<as markup and silently DROPS everything from a literal<onward.Earlier decision: escape-as-text at write viaThat breaks the editor read-back (the textarea would showescapeHtml.<and double-escape on re-save). Final decision: for JOURNEY, skip the HTML sanitizer entirely on the journey branch and persist the intro RAW (the unescaped textarea string). The frontend sendsbodyas the RAW textarea string (no<p>wrapping, no TipTap); the backend stores it verbatim; the reader's existing{@html safeHtml(g.body)}(DOMPurify) is the single escaping/sanitizing site. Add unit testjourney_intro_with_angle_bracket_is_preserved_as_text(raw round-trip through the service), the component tests for raw textarea read-back + raw-on-the-wire PATCH, and the mandatoryjourney_body_render_path_is_sanitizedreader-sanitization test (load-bearing stored-XSS control now that the column holds raw text).JOURNEY creation ownership → a new small dedicated frontend issue (DECIDED; REQ-LE-CREATE-001). Create the
/geschichten/newtype-selector issue and link it as a blocked-by dependency. Without it the editor is unreachable except via a manual DB insert.noteSavingconcurrency model → per-row independent PATCH (DECIDED). Different rows share no state; cross-row serialization adds coupling for no benefit. Each row PATCHes independently with its ownitemId; neither row rolls back the other. Race test asserts both PATCH calls fire with distinct itemIds (toHaveBeenCalledTimes(2)), no rollback on either. Keep a per-rownoteSaving = $state(false)only to prevent two concurrent PATCH calls from the same row on a fast blur.Interlude visual treatment → neutral surface + left accent border + "ZWISCHENTEXT" label (DECIDED). Chosen over warm-orange (reads as caution/alarm to the 60+ audience). Colors MUST be semantic tokens with explicit light AND dark values — never raw
orange-*ortext-blue-600. Add--color-interlude-bg,--color-interlude-border, AND--color-interlude-labeltolayout.css(light + dark); replace everyorange-*/blue-600literal in the spec's impl-ref with tokens; the note-action link usestext-primary(mint/navy). The "ZWISCHENTEXT" label color must independently pass 4.5:1 in BOTH themes (token it, or pin totext-ink-2which is token-safe) and include it in the dual-theme axe assertion. Merge blocker.Item content invariant → block the add until non-empty (REQ-LE-ITEM-001). "Zwischentext hinzufügen" confirm stays disabled (one
$derived) until text is non-empty; backend rejects neither (400). Chosen over auto-DELETE-on-empty-blur (would race the just-completed POST). Symmetric rule now applies to interlude EDIT (REQ-LE-INTERLUDE-NONEMPTY-001): editing an interlude to empty is blocked too.Last item removed from a PUBLISHED journey → allow + inline warning (REQ-LE-PUB-001). Allow removal; show "Diese Reise wird ohne Einträge veröffentlicht bleiben". Reader issue must spec an empty-journey state.
Type immutability enforced at BOTH UI and API (REQ-LE-TYPE-001). No "change type" control. The shared
GeschichteUpdateDTOis mass-assignment-risky oncetypeexists.update()must reject any differingtype:if (dto.getType() != null && dto.getType() != g.getType()) throw conflict(GESCHICHTE_TYPE_IMMUTABLE). Test the THREE-case matrix (round 6):typeomitted/null → 200,typesame → 200 (idempotent),typediffering → 409 (patch_cannot_change_geschichte_type). The single-409 test alone invites a strict-equality bug that over-rejects normal title-only PATCHes (which sendtype: null).create()reads the type fromdto.getType()(no existing entity);update()trustsg.getType()and rejects a differingdto.getType()— two different type sources, spell both out. This is a FIFTH new ErrorCode beyond the four item codes.Item DTOs are minimal Java
records — NOT a shared/fat DTO (DECIDED round 5).record AddItemRequest(UUID documentId, String note),record UpdateItemRequest(String note),record ReorderRequest(List<UUID> itemIds). They MUST NOT reuseGeschichteUpdateDTOor carrytype/status/author/publishedAt— otherwise aPATCH /items/{id}carrying{"note":"x","status":"PUBLISHED"}becomes a mass-assignment vector if any path reads those fields. Add testpatch_item_ignores_unexpected_fields(sendstatus/typein the item PATCH body; assert journey unchanged).Item ownership validation → scoped query, not load-then-compare (DECIDED round 5). Use
findByIdAndGeschichteId(itemId, journeyId).orElseThrow(JOURNEY_ITEM_NOT_IN_JOURNEY)(mirrorsTranscriptionService.getBlock). A rawfindByIdthenif (!item.getGeschichte().getId().equals(journeyId))is a TOCTOU-ish smell, leaks existence via timing, AND — critically for the republish-abuse invariant — loading the managed parent risks dirtying it. Validate ownership with the scoped boolean/scoped-fetch query so no managedGeschichteis mutated. The reorder loop is scoped per-id the same way.Reorder must validate a full permutation of the journey's current items (DECIDED round 5). The submitted
itemIdsarray must have the same size and the same id set as the journey's current items, with no duplicates → else 400. The transcription reorder loops positionally and would assign gapped positions on a partial array. Addreorder_rejects_array_missing_an_item → 400andreorder_rejects_duplicate_item_id → 400(alongside the existing cross-journey IDOR 403 test).Editor loading → static-import both editors (UPDATED round 6 — SUPERSEDES the dynamic-
import()code-split; see Round 6 Resolution #3). BothGeschichteEditor(TipTap, STORY) andJourneyEditor(item list, JOURNEY) are statically imported on the edit page; the{#if geschichte.type === 'JOURNEY'}branch selects which renders. Dynamicimport()was rejected: under SSR +adapter-nodeit renders nothing until the client chunk resolves (flash-of-empty-editor on a slow phone, needing a bespoke skeleton). The savings are asymmetric — STORY already pays for TipTap; only JOURNEY would save by skipping it. Downloading TipTap once on a JOURNEY edit (cached thereafter) is cheaper than a first-paint loading state plus an SSR/client code path. KISS.Branching location →
edit/+page.svelte, not insideGeschichteEditor. MakingGeschichteEditortype-aware via atypeprop violates single-responsibility.GeschichteSidebar.svelte— extract first. Before any Journey logic, extract the Personen + Status sidebar fromGeschichteEditorintoGeschichteSidebar.svelte; both editors import it. On mobile these sections become collapsibles.Confirmation dialog for remove-with-note → inline button pair. Remove of an item with a note transforms the remove button into "Wirklich entfernen?" + Cancel/Bestätigen inline in the row. No
window.confirm().Maximum note length → 2000 characters. Flyway CHECK constraint AND
maxlength="2000"on note textareas AND a backend service guard (DomainException.badRequest(JOURNEY_NOTE_TOO_LONG)) so the CHECK is a backstop, not the primary error path.Hint text minimum size →
text-xs(12px). The spec'stext-[9px]fails WCAG AA. Usetext-xs text-ink-2 mt-1. Non-negotiable.Mobile collapsibles → native
<details>/<summary>with a 44px summary hit area. Native<summary>defaults to ~20px (fails WCAG 2.5.5) — addclass="min-h-[44px] flex items-center px-4".Geschichte.documentsjoin table for JOURNEY type — deprecated. For JOURNEY,GeschichteItemis the ordered source of truth. The backend rejects writes to the unorderedgeschichten_documentsManyToMany for JOURNEY geschichten (in BOTHcreateandupdate). For JOURNEY reads, null out / omitdocumentsin the response. The frontendJourneyEditorreads items from the newitemsfield, never fromgeschichte.documents(whichGeschichteEditoruses on line 47). Add a code comment pointing at this decision.Item-mutation vs. republish semantics (backend product + security decision).
list()orders byCOALESCE(publishedAt, updatedAt) DESC. Item add/remove/reorder on a PUBLISHED journey must NOT bumpupdatedAt(republish recommendation: item mutations do not republish / do not re-sort the public list). Also a content-promotion-abuse vector: a BLOG_WRITE attacker could repeatedly add/remove an interlude to keep a journey pinned. The item endpoints save the item, not the parent; a@OneToManywon't dirty the parent unless code callsgeschichteRepository.save(parent)— and ownership validation must use the scoped query so no managed parent is loaded+mutated (see decision above). Integration test: parentupdatedAt/publishedAtunchanged after an item add on a PUBLISHED journey.Concurrent curation → last-write-wins, stated non-goal. Server-assigns-positions makes the write safe (no duplicate positions), but a stale-order reorder silently clobbers. Acceptable for a few-curator family archive. Explicitly a non-goal: no optimistic-locking conflict detection.
Items persist across PUBLISHED↔DRAFT transitions (round 6). Item content is independent of status; retracting a journey to DRAFT does not touch its items, and the empty-published warning is publish-state-conditioned (clears on retract). Stated to close the unspecified un-publish path.
Silent item removal on document delete → explicit non-goal (round 6). "When a document is deleted, its journey item(s) are removed silently via
ON DELETE CASCADE; the curator is not notified in-app." Acceptable for a family archive (rare, admin-driven deletes). Confirm in the PR that document deletes are already audit-logged so the cascade is reconstructable post-hoc — if so, no new observability hook (do NOT add a journey-specific counter).geschichten_items.document_idFK on document deletion →ON DELETE CASCADE(Option A, DECIDED round 5, was open). Deleting a letter removes its journey item(s) silently — no orphan, no invariant-violating row. Chosen over RESTRICT (cross-domain delete friction + a new error in the document-delete flow) and over SET NULL (REJECTED: produces an item with no doc and no note, violating REQ-LE-ITEM-001). Rationale: document deletion is rare and admin-driven in a family archive; a dangling journey reference is worse than a silently-shortened journey. Add theON DELETE CASCADEclause to the migration FK AND a test (deleting_a_document_removes_its_journey_items).Write an ADR for the
GeschichteTypediscriminator. Record FOUR points: (1) the type discriminator that changes which child collection is authoritative; (2) theGeschichteItemlazy fetch-join read strategy — items fetched alone, persons stay EAGER, never both in one statement; (3) "items is the authoritative ordered collection;geschichten_documentsis frozen for JOURNEY"; (4) JOURNEYcreate(not onlyupdate) rejects/ignoresdocumentIds.Implementation Guidance
Component decomposition (do this first): Extract
GeschichteSidebar.svelte,JourneyItemRow.svelte,JourneyAddBar.svelte(two add buttons + picker state),DocumentPickerDropdown.svelte(single-select wrapper arounduseTypeahead). Define Props interfaces and event signatures first. Target:JourneyEditor.svelteunder 150 lines.{#each}must be keyed by(item.id)— never by position or unkeyed. The corrupted state is localJourneyItemRowstate (the note open/closed toggle + an unsaved draft buffer held in the row's own$state), NOTitem.notebound to the array. So the row MUST hold the draft in its own$statebefore blur for the keying to matter.Pre-mutation snapshot for rollback: before any mutating API call, save
const previousItems = [...items]. On failure:items = previousItems+ show the error. Applies to add, remove, reorder, and note PATCH.Named
$derivedvalues — do not inline in markup:Pass
alreadyAddedIdstoDocumentPickerDropdownforaria-disabledon already-added results. Plain derivedSetis correct — do NOT useSvelteSet.Reorder handler: receives an ordered
string[]of item IDs (fromcreateBlockDragDrop'sonReorder), reorders the local array optimistically, PUTs{ itemIds }in the background, rolls back on failure. No refetch. The client never computesposition. On initial load, sort items bypositionASC; thereafter display order is the array order.DocumentPickerDropdownARIA (round 5 — fix, don't carry forward theDocumentMultiSelectsmell): inputrole="combobox" aria-expanded aria-controls; results containerrole="listbox"; each resultrole="option"witharia-disabled="true"for already-added (accessible name includes "bereits enthalten").DocumentMultiSelect's current per-resultrole="button"(line 161) must NOT be carried forward;aria-disabledonly carries proper semantics inside alistbox. The dropdown must NOT be anaria-liveregion — use listbox +aria-activedescendantso it announces on navigation, not on mutation, keeping it out of the live-region collision the consolidation decision already worries about. Also keep thequery.trim().length >= 1empty-query guard here before callingsetQuery.Two save models, signposted differently: notes save on blur (with
journey_note_save_hint); intro/title save only on Speichern. The intro must NOT carry a blur hint; it shows "Wird mit 'Speichern' gesichert."Intro lives in the title/dirty lifecycle (round 5 clarification): the intro is treated IDENTICALLY to title —
oninputcallsmarkDirty(), its value rides the Speichern PATCHbody, and it is NEVER wired to an immediate per-keystroke PATCH. This reconciles "warn on unsaved intro" with "saved with Speichern, not on blur".Dirty-flag scope + reuse
useUnsavedWarning: a reusable$lib/shared/hooks/useUnsavedWarning.svelte.tsexists (returnsmarkDirty/discard/clearOnSuccess/isDirty).GeschichteEditorhand-rollsbeforeNavigate+window.confirm(lines 103–108) —JourneyEditoradoptsuseUnsavedWarninginstead. Instantiate it at TOP-LEVEL<script>scope (const unsaved = createUnsavedWarning();), never inside a function or$effect, orbeforeNavigatethrows "can only be called during component initialisation." Item mutations are immediate API calls with no pending local state — they MUST NOT callmarkDirty(a spurious dialog would fire). Tests: (a) after an item addisDirtystays false → no warning; (b) a title/intro/person edit sets dirty → warns.Intro field auto-resize:
rows={1}+oninputsettingel.style.height='auto'; el.style.height = el.scrollHeight+'px'; capture viabind:this. Addmax-h-[40vh] overflow-y-autoso it can't grow unbounded and bury the item list / push the sticky save bar (matters at 320px mobile and 400% zoom).csrfFetchcovers DELETE and PUT (cookies.tsline 37). The reorderPUTMUST go throughcsrfFetch, not rawfetch. Match existing transcription reorder.Do NOT copy transcription's reorder error handling.
TranscriptionEditView.svelteline 128 swallows errors (catch { /* ignore */ }) with no rollback. Journeys deliberately diverge: rollback + visible error. Reviewers must not "match" the PR to that precedent.type="button"mandatory on all non-submit buttons inJourneyEditor/JourneyAddBar— both may be inside a<form>.Permission check already satisfied — do not regress.
edit/+page.server.tsline 8 already enforcescanBlogWriteand redirects. Do not re-implement; do not remove.Micrometer cardinality (round 5): the five item endpoints must use
@PathVariable UUID itemIdin templated paths (/items/{itemId}) so thehttp_server_requestsuritag stays templated and low-cardinality. A literal id in the route string would explode cardinality.After implementation: run
npm run generate:apiinfrontend/once backend types are merged, andnpm run check 2>&1 | grep -E "src/lib/geschichte|src/routes/geschichten"to confirm no new svelte-check errors (informational only — the type-regression GATE is the Vitest/tscguard, not svelte-check).First step in worktree:
npm installinfrontend/before any code changes (pre-commit hook runsnpm run lint). No new dependency is added.Update
src/lib/geschichte/README.mdto document the new components + props as part of this PR.Accessibility Requirements
<div aria-live="polite" aria-atomic="true">inJourneyEditor.svelteannouncing item position changes when an item is moved (via the move-up/down buttons — round 6, not keyboard-drag), via parameterized i18n keyjourney_item_moved— no hardcoded German.journey_item_pending_add/_remove) but make those regions live only while pending and remove them from the DOM on resolve (not just empty them), so twoaria-live="polite"regions don't collide. The document picker is a THIRD potential live surface — it must use listbox/aria-activedescendantsemantics (NOT a live region) so it stays out of this collision.opacity-60with the per-row status removed on resolve.<button class="p-3 -m-1">(or equivalent negative margin) so the touch target is ≥44×44px while the visible column stays narrow.w-4(16px) fails WCAG 2.5.5/2.5.8. Useclass="w-11 flex items-center justify-center cursor-grab"with the glyph centered.<button class="min-h-[44px] inline-flex items-center gap-1 px-2 text-sm font-semibold text-primary">with a leading icon (+ / ✕).aria-disabled="true"(not HTMLdisabled) on the Publish button so anonclickcan open the Status collapsible and scroll to it when tapped while disabled.aria-hidden="true"on the "REISE" type-badge span. Because the badge is hidden, the accessibility tree needs a non-visual mode cue: make the edit-page<h1>(currently{m.btn_edit()}: {title}, type-neutral, edit/+page.svelte line 48) or a visually-hidden sibling type-aware — "Lesereise bearbeiten" for JOURNEY vs "Geschichte bearbeiten" for STORY — so a blind curator knows which editor mode they're in.AxeBuilderE2E scan must run in BOTH themes (add the theme-toggle step) and assert all three interlude tokens (bg, border, label).<details>/<summary>mobile collapsibles withmin-h-[44px] flex items-center px-4on each<summary>.<sm: handle + position + title on row 1; note toggle + remove on a second line. Never truncate the document title. Usemin-w-0+break-words, full-width on mobile.prefers-reduced-motion. (The keyboard-drag settle is moot — round 6 replaced keyboard-drag with move-up/down buttons.)min-h-[44px] min-w-[44px]hit area,journey_move_up/journey_move_downaria-labels with{title}). Disable "up" on the first row and "down" on the last. They fire the same optimistic-reorder + background-PUT + rollback path as pointer-drag and trigger thejourney_item_movedannouncement.journey_item_movedannouncement and per-row pending status interpolate{title}; document titles are attacker-influenceable via import — never{@html}in a live region (reflected XSS).Security Flags (for backend implementer)
GET /itemsendpoint — items are a FIELD on the existing guardedGET /api/geschichten/{id}/list, inheriting the DRAFT 404 guard. A separate items GET (or a guard-freefindByIdWithItems) re-opens the DRAFT leak.typein PATCH withDomainException.conflict(GESCHICHTE_TYPE_IMMUTABLE). Test create (type honored) AND update (type rejected → 409).findByIdAndGeschichteId(itemId, journeyId).orElseThrow(JOURNEY_ITEM_NOT_IN_JOURNEY)for PATCH/DELETE; reorder loop scoped per-id likegetBlock. Never load-then-compare (TOCTOU smell + risks dirtying the managed parent → republish abuse).records (AddItemRequest(documentId, note),UpdateItemRequest(note),ReorderRequest(itemIds)) — no shared DTO with the parent, notype/status/author/publishedAt. Testpatch_item_ignores_unexpected_fields.createANDupdatereject/ignoredocumentIds(frozengeschichten_documents).POST /itemsvalidates thedocumentIdviadocumentService.getDocumentById(same pathresolveDocumentsuses) so a non-existent/forbidden id yields a mapped 404DOCUMENT_NOT_FOUND, NOT a rawfindById(dangling FK / 500).@RequirePermission(Permission.BLOG_WRITE)on ALL five new item endpoints incl. the reorderPUT. A 403 test per endpoint, plus a 401-unauthenticated test for at least the reorder endpoint.notecolumn/field: "stored as plain text; render with text interpolation only — never {@html}." Holds for any future "rich note" feature.geschichten_itemsmust include:(geschichte_id, position).(geschichte_id, document_id) WHERE document_id IS NOT NULL— DB-level document dedup. CatchDataIntegrityViolationException→DomainException.conflict(JOURNEY_DOCUMENT_ALREADY_ADDED).notemax length (2000 chars).document_idOR non-emptynote).document_id → documentsON DELETE CASCADE(Option A — deleting a letter removes its journey items; no orphan, no invariant violation).geschichten_items; does not read or modifygeschichten_documents; no backfill (no JOURNEY rows exist pre-migration)." Add toMigrationIntegrationTest. Confirm the new version number is the strict max (FK ordering: table created aftergeschichtenanddocuments).ErrorCodevalues (add toErrorCode.java,errors.ts, all threemessages/*.json):JOURNEY_ITEM_NOT_FOUND(404),JOURNEY_ITEM_NOT_IN_JOURNEY(403),JOURNEY_NOTE_TOO_LONG(400),JOURNEY_DOCUMENT_ALREADY_ADDED(409),GESCHICHTE_TYPE_IMMUTABLE(409).{text}interpolation, never{@html}. Also applies to live-region announcements (see Accessibility).i18n Keys Required
All three language files need at minimum:
journey_add_document— "Brief hinzufügen"journey_add_interlude— "Zwischentext hinzufügen"journey_note_add— "Notiz hinzufügen"journey_note_remove— "Notiz entfernen"journey_note_save_hint— "Wird gespeichert, wenn du das Feld verlässt."journey_intro_save_hint— "Wird mit 'Speichern' gesichert."journey_already_added— "Bereits enthalten"journey_note_aria_label— "Kuratoren-Notiz für {title}"journey_drag_aria_label— "Reihenfolge von '{title}' ändern"journey_move_up— "'{title}' nach oben verschieben" (move-up button aria-label — round 6)journey_move_down— "'{title}' nach unten verschieben" (move-down button aria-label — round 6)journey_note_error— "Notiz konnte nicht gespeichert werden"journey_item_moved— "Eintrag {position} von {total} — nach Position {newPosition} verschoben"journey_remove_confirm— "Wirklich entfernen?"journey_mutation_error_reload— (rollback error message with reload prompt)journey_item_pending_add— "wird hinzugefügt…"journey_item_pending_remove— "wird entfernt…"journey_published_empty_warning— "Diese Reise wird ohne Einträge veröffentlicht bleiben"Test Plan
Component tests (
*.svelte.spec.ts, vitest-browser):edit/page.svelte.spec.ts— branch tests (write first): renders TipTap toolbar (role="toolbar") when STORY; does not when JOURNEY.JourneyEditor: empty state renders; item list in position order; Publish disabled whenitems.length === 0; Publish disabled whentitle.trim() === ''; POST called withdocumentIdon picker confirm; POST called withnoteon interlude submit; "Zwischentext" confirm disabled until text non-empty; DELETE called on remove click; rollback on failed mutation; item-add does NOT mark dirty (isDirtystays false); typingTemperatur < 0into intro + submit PATCHesbody: 'Temperatur < 0'(un-escaped on the wire).csrfFetchper-test withmockResolvedValueOnce()for EVERY call in the path. Reorder is optimistic-no-refetch → its success AND rollback tests asserttoHaveBeenCalledTimes(1)on the reorder PUT URL (one PUT, no GET). ThetoHaveBeenCalledTimes(2)"PUT/PATCH + GET" pattern belongs ONLY to add/remove paths that refetch — NOT to reorder. The note race test assertstimes(2)across two DISTINCT itemIds (two independent PATCHes), which is a different surface. Assert (1) item list unchanged after rollback, (2) error message visible, (3) exact call count per the above.mockResolvedValueOnce({ ok: false, status: 409 })(non-ok response) and another usesmockRejectedValueOnce(new Error('network'))(promise rejection). The transcription precedent handles NEITHER (if (!res.ok) return;+catch { /* ignore */ }), so both are genuine regressions to guard. Same rollback path, two triggers.journey_body_render_path_is_sanitized— assert the reader (geschichten/[id]/+page.svelte) interpolatesbodythroughsafeHtml/{text}, never raw{@html geschichte.body}. Mandatory because JOURNEYbodyis now stored RAW.JourneyItemRow: note textarea opens on "Notiz hinzufügen"; PATCH called on blur with non-empty value; textarea collapses + note cleared on blur empty (DOCUMENT item only); "Notiz entfernen" visible only when note non-empty AND item is a document-item (ABSENT for note-only interludes); removearia-labelcontains document title; inline confirmation on remove-with-note; confirmation cancel restores item.{#each}corruption test (red first): render 2 items; OPEN item-2's note textarea (toggle state) AND type an unsaved draft held in the ROW's own$state(do NOT bind toitem.note, do NOT blur); reorder to swap 1↔2 by invoking the reorder handler/callback directly (the synchronous local-array reorder), NOT by simulating a pointer drag — pointer-capture +getBoundingClientRecthit-testing is flaky in headless vitest-browser (round 6). PUT merely mocked-to-resolve, no refetch await needed. Assert BOTH the textarea is still open AND the draft text is intact on the row now at position 1. The single real pointer/keyboard-button drag lives only in the Playwright E2E. Add a comment so a future dev doesn't "simplify" the key away.noteSavingrace test (per-row independent PATCH): blur row A (PATCH in-flight), immediately blur row B → assert both PATCH calls fire with distinct itemIds (toHaveBeenCalledTimes(2)), neither rolls back the other.role="option" aria-disabled="true"and its accessible name includes "bereits enthalten".picker does not call fetch on empty query(the< 1 charguard).Mock boundary:
vi.mock('$lib/shared/cookies', () => ({ csrfFetch: vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }) })). UsemockResolvedValueOnce()per-test. Trigger blur withdispatchEvent(new FocusEvent('blur')).Load-function test: import
edit/+page.server.tsdirectly; assertload()redirects to/geschichten/{id}whencanBlogWriteis false.Type-regression guard (NOT svelte-check): a Vitest type-test or
tsc --noEmitover the changed files asserting the transcription editor still compiles against the generalizedcreateBlockDragDrop<T>signature. Must run in the Vitest/tsc gate (CI does not gate svelte-check).Backend integration tests (Testcontainers Postgres, NOT H2):
reorder_returns_403_when_an_itemId_belongs_to_another_journeyreorder_rejects_array_missing_an_item → 400reorder_rejects_duplicate_item_id → 400patch_item_returns_403_when_item_not_in_journeydelete_item_returns_403_when_item_not_in_journeypatch_item_ignores_unexpected_fields(sendstatus/type; journey unchanged)reject_item_with_no_document_and_empty_note → 400(REQ-LE-ITEM-001)duplicate_document_add_returns_409(partial UNIQUE)patch_cannot_change_geschichte_type → 409(REQ-LE-TYPE-001 API enforcement) — plus the two PASS cases:patch_with_type_omitted → 200andpatch_with_same_type → 200(round-6 three-case matrix)journey_create_ignores_documentIds(frozen join table on the create path)journey_intro_with_angle_bracket_is_preserved_as_text(REQ-LE-INTRO-001 — raw round-trip, NOT escaped)note_with_script_tag_is_stored_and_rendered_as_text(note is verbatim plaintext;<img onerror>renders literal in editor + reader)item_add_on_published_journey_does_not_change_parent_updatedAt(republish/promotion-abuse invariant)intro_save_on_published_journey_DOES_update_updatedAt(round-6 positive counterpart — pins that an intro/title Speichern DOES republish, so a wrong "freeze updatedAt everywhere" fix is caught)deleting_a_document_removes_its_journey_items(FKON DELETE CASCADE)get_journey_returns_items_in_position_order— real@SpringBootTest+ Testcontainers hittingGET /api/geschichten/{id}; assert serializeditemspopulated andORDER BY positionANDdocumentsis null/empty for the JOURNEY (the "frozen for JOURNEY" decision's only test). Only this HTTP-layer test catches a Lazy/serialization mismatch.parallel_item_posts_get_distinct_positions— fire two POSTs concurrently; assert positions 0 and 1, no UNIQUE violation surfaced to the client (server serializes position assignment).These IDOR/constraint tests belong permanently in the regression suite.
E2E (Playwright, ONE journey test): full curator path — add document → add interlude → reorder → verify order persists on reload. Use the move-up/down buttons for reorder (deterministic; round 6 dropped keyboard-drag in favor of move buttons). One
AxeBuilderscan at the end in BOTH light and dark themes, AND at a reflow width (320 CSS px / 400% zoom) asserting the interlude row stacks without the handle+position+accent-border colliding. Keep to ONE E2E (budget <8min). Do NOT drive 50 add-letter UI clicks — seed the 50-item NFR fixture via@Sql/a parallel-safe test-data API helper and assert the 50-item RENDER + one keyboard-drag completes without error or spinner. Budget-checkbeforeAllseeding time.NFR
The editor must render and reorder up to 50 items without error or a page-level / blocking spinner (round 6 disambiguation — the required per-row optimistic pending indicator,
opacity-60+ "wird hinzugefügt…", is exempt and required; it is NOT the spinner this NFR forbids). The unmeasurable "no layout reflow" language is dropped. Made testable via the 50-item E2E fixture seeded out-of-band (@Sql/parallel-safe test-data helper, NOT 50 UI clicks; budget-checkbeforeAllseeding). No NFR for larger counts.DevOps / Deploy Notes
svelte-dnd-action/@dnd-kitwithdrawn;useTypeahead/createBlockDragDropreused). No env var, no service, no Compose change. PR description must assert: "Nopackage.json/pom.xmlchange. Reviewer: confirm both dependency manifests have empty diffs." The committeddocs/specs/lesereisen-editor-spec.htmlSTILL says "@dnd-kit/coreodersvelte-dnd-action" in LE-2 impl-ref and STILL shows raworange-*/blue-600classes — strike/update those stale lines; follow the issue body, not the HTML spec, on any disagreement.{itemId}templating so Micrometeruritags stay low-cardinality.tsc --noEmit, NOT svelte-check (CI doesn't gate svelte-check).Documentation Blockers (required for PR merge)
GeschichteItementity →docs/architecture/db/db-orm.puml+docs/architecture/db/db-relationships.puml(new entity + FK togeschichten+ FK todocumentswith CASCADE)GeschichteTypeenum onGeschichte→docs/architecture/db/db-orm.pumlattribute updateGeschichteTypediscriminator (4 points: discriminator; fetch strategy = items-alone fetch-join, persons EAGER, never both; "documents frozen for JOURNEY"; "create rejects documentIds") →docs/adr/ErrorCodevalues (incl.GESCHICHTE_TYPE_IMMUTABLE) →CLAUDE.mderror handling sectionsrc/lib/geschichte/README.mdupdate (new components + props)@dnd-kit/svelte-dnd-actionand raworange-*/blue-600lines indocs/specs/lesereisen-editor-spec.htmlConfirmed Risks to Watch
createBlockDragDropinstead of generalizing it — recreates the two-DnD-systems problem. The selector contract (data-block-wrapper/data-drag-handle) must be preserved or pointer hit-testing silently no-ops.tsc.LazyInitializationExceptionon the JOURNEY read path — only the@SpringBootTestHTTP-layer test catches it.personsin the same statement — fetch items alone.<or&if the HTML sanitizer runs on the JOURNEY branch.fetchUrlreturns the{items:[...]}wrapper without.then(b => b.items)(renders nothing).typeflip via PATCH bypassing the hidden UI control; and fat item DTOs leakingstatus/type.create()populatesgeschichten_documentsfor a JOURNEY unless guarded (round-5 miss).updatedAt— use scoped ownership queries so no managed parent is loaded+mutated.{#each}test passing for the wrong reason if the draft is bound toitem.noterather than held in row-local$state; and flaking if it simulates a pointer drag instead of invoking the reorder handler directly.<in the textarea and double-escapes (&lt;) on re-save — the editor half of REQ-LE-INTRO-001 fails. Store RAW; escape only at the reader.body(round 6): now that the column holds raw text, any render path that isn'tsafeHtml/{text}is stored XSS. Thejourney_body_render_path_is_sanitizedtest is the load-bearing control.import()of the editor under SSR + adapter-node renders nothing until the client chunk resolves — static-import both editors instead.max-h-[40vh]intro + item list collide; the plain 320px render test misses it unless it focuses a field first. Dropstickywhile a field is focused.type: nullPATCH breaks every normal title-only save — test the three-case matrix (omitted/same/different).getById@Transactionalin place changes STORY session semantics — use the separategetByIdWithItemsdelegating method.intro_save_DOES_bump_updatedAttest, someone could satisfy the negative test by freezingupdatedAteverywhere. Pin both directions.ON DELETE CASCADEremoves a published journey's item when a letter is deleted elsewhere, with no in-app notice — accepted, but confirm document deletes are audit-logged so the change is reconstructable.UI spec committed to main:
docs/specs/lesereisen-editor-spec.htmlCovers four screens:
JourneyEditor(Titel-Input, optionale Einleitung, Leerstate-Platzhalter, Veröffentlichen disabled bis ≥1 Item + Titel)Desktop- und Mobile-Mockups + impl-ref-Tabellen für alle vier Screens. Enthält API-Referenz für alle fünf Item-Endpoints (add/note/remove/reorder).
marcel referenced this issue2026-06-07 19:38:21 +02:00
marcel referenced this issue2026-06-07 19:39:52 +02:00
marcel referenced this issue2026-06-07 19:40:20 +02:00
marcel referenced this issue2026-06-07 19:40:58 +02:00
marcel referenced this issue2026-06-07 19:47:26 +02:00
marcel referenced this issue2026-06-07 19:48:18 +02:00
marcel referenced this issue2026-06-08 10:45:15 +02:00
marcel referenced this issue2026-06-08 10:46:43 +02:00
marcel referenced this issue2026-06-08 10:51:41 +02:00
marcel referenced this issue2026-06-08 10:54:39 +02:00
marcel referenced this issue2026-06-08 10:56:16 +02:00
marcel referenced this issue2026-06-08 11:00:58 +02:00
marcel referenced this issue2026-06-08 11:01:41 +02:00
marcel referenced this issue2026-06-08 11:04:33 +02:00
marcel referenced this issue2026-06-08 11:06:04 +02:00
marcel referenced this issue2026-06-08 11:06:38 +02:00
marcel referenced this issue2026-06-08 11:14:56 +02:00
marcel referenced this issue2026-06-08 11:16:25 +02:00
marcel referenced this issue2026-06-08 11:17:04 +02:00
marcel referenced this issue2026-06-08 11:17:21 +02:00