feat(lesereisen): backend item CRUD API — add, update, remove, reorder JourneyItems #751
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
Expose the
JourneyItemmodel via REST endpoints so the frontend curator editor can build, reorder, and annotate a Reading Journey.Background
Builds on the data model from the preceding issue (GeschichteType + JourneyItem entity). Design spec:
docs/superpowers/specs/2026-06-06-lesereisen-design.md.API surface
All endpoints require
BLOG_WRITEpermission.POST body
At least one of
documentIdornote(non-blank after trim) must be present; the backend returns 400 if both are absent or the note trims to empty with no document.New item is appended with
position = max(existing positions) + 10. If no items exist yet, position starts at 10.POST to a non-JOURNEY-type Geschichte returns 400. POST with a
documentIdthat does not resolve to a Document returns 404DOCUMENT_NOT_FOUND(see Review Insights — falls out ofdocumentService.getDocumentById(), semantically honest).POST response
Returns the created item as a
JourneyItemViewrecord (same shape as the GET item sub-object — see Review Insights "Read-model shape"):PATCH /items/{itemId}
Only the
notefield is mutable after creation.documentIdandpositioncannot be changed via PATCH (position changes go through/reorder). Attaching a document to an existing note-only interlude is a non-goal (see Review Insights) — curator deletes and re-adds. Note is plain text — no HTML.note: nullis accepted and clears the note field (item must still have a document if note is cleared — returns 400 if item has no document). A note that trims to empty is stored asnull.PUT /reorder body
Backend reassigns positions 10, 20, 30, … in the given order. Returns 400 if the provided IDs don't match the journey's current items (set equality — both extra IDs and missing IDs trigger 400). Empty
[]on an empty journey returns 200 (valid no-op); empty[]on a non-empty journey returns 400.itemIds.size()is capped atmax(MAX_ITEMS, currentItemCount)(so grandfathered over-cap journeys stay reorderable — see Review Insights) before the set-equality check (defense-in-depth).PUT /reorder response: returns the updated items array (same shape as
GET /api/geschichten/{id}items), enabling optimistic-update reconciliation on the frontend without a follow-up GET.Updated GET /api/geschichten/{id} response
The
documentsarray is replaced byitemsfor JOURNEY type only. Non-JOURNEY Geschichten retain the existingdocumentsfield unchanged — no breaking change to existing Geschichte display.itemsis only present whentype == JOURNEY— discriminated union by type:Items are returned ordered by
position ASC— this is the guaranteed display order for the reader view.This is a breaking change — run
npm run generate:apiinfrontend/after this lands.Implementation notes
JourneyItemServiceowns allJourneyItemRepositorycalls — do not add item methods toGeschichteService(already ~200 lines; would exceed 300). Model:AnnotationServicefor the annotation sub-domain.GeschichteControllercallsjourneyItemServicedirectly for item operations (avoids circular injection under Spring Framework 7 rules). The append method is namedappend(notcreate) to match the AC vocabulary.GeschichteControllerdelegates toJourneyItemService;GeschichteServiceis not the intermediary for item operations.findByIdAndGeschichteId(itemId, geschichteId)in the repository for all PATCH and DELETE operations — one query, correct ownership check. ReturnsDomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, ...)(notforbidden()) to avoid confirming item existence. Do not usefindById+ manual check — that's two queries and easy to accidentally skip under refactoring.findByIdAndGeschichteIdmust be the only access path for PATCH/DELETE — flag any barefindByIdinJourneyItemServiceduring code review. Precedent:TranscriptionService.getBlockalready usesfindByIdAndDocumentId(...).orElseThrow(...notFound...)— cite it in the PR as house style.ErrorCode.JOURNEY_ITEM_NOT_FOUNDmust be added: (1)ErrorCode.java, (2)frontend/src/lib/shared/errors.ts, (3)messages/{de,en,es}.jsonwith keyerror_journey_item_not_found.ErrorCode.JOURNEY_ITEM_POSITION_CONFLICTmust also be added (same four places, keyerror_journey_item_position_conflict) — see Review Insights for the 409 mapping.BODY_SANITIZERpass, no TipTap in the future editor (use<textarea>). Enforce with@Column(columnDefinition = "TEXT"). Same pattern asGeschichte.bodyline 34. Stored verbatim (HTML/script bytes stored as-is, escaping is the frontend's job via{note}text binding) — never run note throughBODY_SANITIZER.SELECT MAX(j.position) FROM JourneyItem j WHERE j.geschichte.id = :idinside@Transactional— race-safe within the transaction.MAXreturns null on an empty table —COALESCE/null-handling must default to 0 so the first item lands at 10. Backed byUNIQUE(geschichte_id, position)at the DB layer (see Review Insights).SELECT id FROM journey_items WHERE geschichte_id = ?. A count-only check is insufficient — must use set equality to catch IDs from other journeys. Implementation:Set<UUID> existing = repository.findIdsByGeschichteId(geschichteId)→if (!existing.equals(Set.copyOf(dto.getItemIds())))→ throw 400. CapitemIds.size()atmax(MAX_ITEMS, currentItemCount)before the equality check.JourneyItemis owned by thegeschichtedomain.geschichte/package):JourneyItemCreateDTO(documentId + note, both optional),JourneyItemUpdateDTO(note only — useJsonNullable<String>to distinguish absent vs. explicit null; test: absent→unchanged, null→cleared, "text"→set),JourneyReorderDTO(List<UUID> itemIds).@Transactionalon reorder method — required because it's a loop of Nsave()calls.DocumentSummaryrecord{ id, title, documentDate, documentDateEnd, datePrecision, senderName, receiverName, receiverCount }(see Review Insights for final shape and rationale). Built byJourneyItemServiceviadocumentService— never a serialized JPA@ManyToOne.maintoday is V71, predecessor will take V72, so this issue is V73+), not at issue creation time. SQL must include:CHECK (position > 0)constraint onjourney_items.position;CONSTRAINT uq_journey_items_geschichte_position UNIQUE (geschichte_id, position)(named explicitly — see Review Insights, the 409 remap matches this name) declaredDEFERRABLE INITIALLY DEFERRED(see Review Insights "Reorder position writes");REFERENCES geschichten(id) ON DELETE CASCADEonjourney_items.geschichte_id;REFERENCES documents(id) ON DELETE SET NULLonjourney_items.document_id(item becomes note-only when document is deleted — preserves curator work). Include data migration fromgeschichten_documentstojourney_itemswith positions 10, 20, 30… viaROW_NUMBER() OVER (PARTITION BY geschichte_id ORDER BY document_id) * 10(UUID ordering — deterministic but not semantically meaningful; add a SQL comment explaining this). Statement order is load-bearing (see Review Insights): (1) CREATE TABLE → (2) INSERT … SELECT FROM geschichten_documents → (3)DO $$parity guard → (4) DROP TABLE geschichten_documents — the guard sits textually between INSERT and DROP. Add a SQL comment:-- MUST run in a single transaction; the parity guard's rollback of the DROP depends on it. Do not add CONCURRENTLY or set non-transactional.docs/architecture/db/db-orm.pumlanddocs/architecture/db/db-relationships.pumlas part of this PR — treat as a blocker. Removegeschichten_documentslines (db-orm.puml ~373–375, 439–440; db-relationships.puml ~69, 132–133), addjourney_itemswith its three columns and two FK relationships.@Schema(requiredMode = REQUIRED)onJourneyItem.id,JourneyItem.position— mandatory for correct TypeScript type generation.@JsonInclude(JsonInclude.Include.NON_NULL)on the responseitemsfield — ensuresitemsis absent (notnull) for non-JOURNEY GET responses. Verify with a test assertingjsonPath("$.items").doesNotExist().GeschichteControllerTest: add@MockitoBean JourneyItemService journeyItemService;before writing any new tests — prevents the existing controller test from breaking whenGeschichteControllergains the new dependency.auditService.logAfterCommit(...)— see Review Insights "Audit — wiring against the real AuditService" for the required newAuditKindvalues, thedocumentId == nullcontract, and the rollup decision.notefield must be rendered as{note}(Svelte text binding, NOT{@html note}). Applywhite-space: pre-line(NOTpre/pre-wrap— would break 320px wrapping) so curator line breaks are preserved. Never render note with.innerHTML.geschichte/package entry to listJourneyItemServiceandJourneyItemRepository. Also fixbackend/CLAUDE.mdTesting section: the stale "88% branch coverage" line must read 0.77 (matchespom.xmlline 349).Acceptance criteria
POST /api/geschichten/{id}/itemscreates a JourneyItem and returns it (with embedded document summary)POST /itemson a GESCHICHTE-type Geschichte returns 400POST /itemswith adocumentIdthat does not resolve to a Document returns 404DOCUMENT_NOT_FOUND— testpost_returns404_when_documentIdDoesNotExistPATCH /api/geschichten/{id}/items/{itemId}updates the note field only (plain text)DELETE /api/geschichten/{id}/items/{itemId}removes the itemDELETE /api/geschichten/{id}/items/{itemId}on an already-deleted item returns 404JOURNEY_ITEM_NOT_FOUND(frontend treats as idempotent success) — testdelete_returns404_when_itemAlreadyDeletedPUT /api/geschichten/{id}/items/reorderreassigns positions in the given order and returns the updated items arrayGET /api/geschichten/{id}returnsitemsarray ordered byposition ASCfor JOURNEY type;itemsfield absent for non-JOURNEY typeGET /api/geschichten/{id}for GESCHICHTE type still returnsdocumentsfield unchanged (no regression)400 returned when POST body has neither
documentIdnornote, OR note trims to empty with no documentNote that trims to whitespace-only is stored as
null(POST and PATCH)All endpoints require and enforce
BLOG_WRITETests cover the new endpoints and the 400 validation
PATCH/DELETE return 404 (not 403) when
itemIdbelongs to a different journey (IDOR protection viafindByIdAndGeschichteId)PUT /reorderreturns 400 when provided IDs contain extra or missing items (set equality check)PUT /reorderwith IDs from a different journey triggers 400 (set equality, not count-only) — test namedreorder_returns400_when_itemIdBelongsToADifferentJourney, authenticated as a legitimate BLOG_WRITE user against journey A smuggling journey B's item ID (isolates IDOR from the permission check)PUT /reorderwith empty[]on empty journey returns 200; with empty[]on non-empty journey returns 400PUT /reorderwithitemIds.size() > max(MAX_ITEMS, currentItemCount)returns 400 before the set-equality check (size guard)reorder_of_grandfathered_over_cap_journey_succeeds— a migrated 130-item journey reorders successfully (size cap =max(100, currentCount))reorder_swap_two_adjacent_items_succeeds— 10↔20 swap succeeds without a transientUNIQUEviolation (proves the deferrable constraint / atomic commit)reorder_returns_items_in_new_order— asserts response bodyitems[0].id == requestedFirstId && items[0].position == 10(not status-only)reorder_with_identical_current_orderreturns 200 (idempotent no-op)reorder_conflict_surfaces_as_409— a position conflict on the reorder path surfaces as 409JOURNEY_ITEM_POSITION_CONFLICT, not 400 (shared remap helper covers bothappendandreorder)append_after_reorder_continues_from_max_position— append after a reorder computesMAX+10off the new positionsappend_to_empty_journey_starts_at_10—MAXreturns null on empty table; COALESCE/null-handling pins position 10 (off-by-one guard)PATCHwithnote: nullclears the note field (accepted when document is present)PATCHwithnote: nullreturns 400 when item has no document (would result in item with neither document nor note)PATCHwith the same note value (no-op) returns 200 idempotentlyJourneyItemUpdateDTO.notethree-way semantics tested against the real deserializer (MockMvc, real Jackson): absent→unchanged, null→cleared, "text"→set; assert viaargThat/follow-up GETGET /api/geschichten/{id}withoutitemsfield when type isGESCHICHTE— test assertsjsonPath("$.items").doesNotExist()GET a JOURNEY with items does not throw
LazyInitializationException(integration test, not slice)getById_returns_items_for_draft_journey_when_user_has_BLOG_WRITE— DRAFT JOURNEY read path returns items (curator's actual editor path)getById_throws_NOT_FOUND_for_draft_journey_when_user_lacks_BLOG_WRITE— the existing DRAFT-hiding guard still fires for JOURNEY; theitemsload happens only after the guard passesJOURNEY_ITEM_NOT_FOUNDandJOURNEY_ITEM_POSITION_CONFLICTErrorCodes added in backend, frontend errors.ts, and all three i18n filesFlyway migration includes
ON DELETE CASCADE(geschichte FK),ON DELETE SET NULL(document FK),CHECK (position > 0), and namedDEFERRABLE INITIALLY DEFERREDUNIQUE(geschichte_id, position)Flyway migration SQL includes a comment on the
ORDER BY document_iddata migration ordering AND the single-transaction/no-CONCURRENTLY commentMigration statement order is CREATE → INSERT → parity-guard
DO $$→ DROP, in that textual order (guard between INSERT and DROP)Migration includes a row-count parity guard that aborts before any DROP if
journey_itemscount !=geschichten_documentscountIntegration test (service-level): delete a Document via
documentService.delete(), verify JourneyItem still exists withdocument = nullIntegration test (DB-level, raw SQL):
DELETE FROM documents WHERE id=?via jdbcTemplate → re-fetch JourneyItem →document_idis null (proves the FK, isolated from JPA cascade)Data-migration integration test: seed
geschichten_documentswith N rows across M geschichten via raw SQL/@Sqlbefore migration → run migrations → assertjourney_itemshas exactly N rows; per-geschichte assert thepositionset equals{10,20,…,10*n}and the row count — NO document→position coupling (UUID order is arbitrary; flake-proof)Migration parity-guard test: a deliberately-seeded count mismatch leaves
geschichten_documentsintact (not dropped) — proves theRAISE EXCEPTIONrolls back the DROP in the same Flyway transactionposition_check_rejects_nonpositive— raw insert via jdbcTemplate expectingDataIntegrityViolationException(postgres:16-alpine only)unique_constraint_rejects_duplicate_position_per_geschichte— raw insert of duplicate (geschichte_id, position) expecting violation (postgres only)Position-race contract: duplicate-position insert through
JourneyItemService.appendsurfaces as 409JOURNEY_ITEM_POSITION_CONFLICT(not 400VALIDATION_ERROR); the remap matches the pinned constraint nameuq_journey_items_geschichte_position(a renamed-constraint regression goes red); the 409-remap test is a service/slice test with real@Transactional, NOT a member of the non-@TransactionalJourneyItemConstraintsTestRegression test: note containing HTML/
<script>is stored and returned verbatim by GET (no sanitization, no escaping at the API layer); the journey-with-note GET assertscontent().contentType(MediaType.APPLICATION_JSON)DocumentSummaryround-trips ALL sevenDatePrecisionvalues — specifically a source document withmetaDatePrecision = SEASONand one withAPPROXround-trip that exact value through GET (no defaulting toUNKNOWN)Multi-receiver
DocumentSummary: a document with ≥2 linked receivers returnsreceiverName= first canonical receiver andreceiverCount= total countreceiverCountcontract: non-null for any document-bearing item;0when no receiver is linked (thenreceiverName == null);>=1otherwise — testsummary_returns_receiverCount_0_and_null_name_when_no_receiverName composition tested: linked Person →
firstName lastName(trimmed); no Person →senderText/receiverText; neither → nullfindByIdAndGeschichteIdrepository method tested: item ID matches but geschichte ID doesn't → empty; item ID doesn't exist → emptyIDOR test:
PATCH /api/geschichten/{journeyAId}/items/{itemFromJourneyBId}authenticated as a valid BLOG_WRITE user → 404, namedpatch_returns404_when_itemBelongsToADifferentJourneyController tests cover 401 (unauthenticated) AND 403 (authenticated with READ_ALL but not BLOG_WRITE) for each write endpoint
@MockitoBean JourneyItemServiceadded toGeschichteControllerTestExisting
GeschichteServiceTest/GeschichteServiceIntegrationTest/GeschichteControllerTestpass after theupdate()JOURNEY-guard is added (regression — fix any test callingupdate()withdocumentIdson a now-JOURNEY story)GeschichteService.update()rejectsdocumentIdson JOURNEY-type stories (prevents dual-write divergence); guard readsg.getType()from the entity —typeis NOT added toGeschichteUpdateDTO(no type flips via PATCH)Soft cap of 100 items per journey enforced on POST; the 101st returns 400 (
VALIDATION_ERROR); the cap test seeds the first 100 items via@Sql/jdbcTemplate fixture, NOT 100 HTTP calls. Grandfathered journeys migrated over the cap are editable (incl. reorder) except for appending new itemsSecurity-assumption regression test: a
READ_ALL-only user (noBLOG_WRITE) GETs a published JOURNEY and receives theitems[].documentsummaries — codifies the "uniform document reads" precondition; goes red the day document reads are scopedAudit: add/remove/reorder each record an audit event via
auditService.logAfterCommit(<new AuditKind>, actorId, null, Map.of("geschichteId", …))—documentIdarg is null; test verifies the call fires withdocumentId == nullThree new
AuditKindvalues added (JOURNEY_ITEM_ADDED,JOURNEY_ITEM_REMOVED,JOURNEY_ITEMS_REORDERED) with Javadoc payload docs;JOURNEY_ITEMS_REORDEREDadded toROLLUP_ELIGIBLE;AuditKindenum addition as its own atomic commit before the service workDB diagram files updated (
db-orm.puml,db-relationships.puml)npm run generate:apirun and TypeScript types regenerated AND committed in this PR (verify viagit diff --statonfrontend/src/lib/api/...)CLAUDE.mdupdated to listJourneyItemServiceandJourneyItemRepositoryin thegeschichte/package entry;backend/CLAUDE.mdTesting section corrected from "88%" to0.77docs/GLOSSARY.mdentries added for "Lesereise / Reading Journey", "JourneyItem", "Interlude" (note-only item), and the three newAuditKindvaluesEditor design spec (
lesereisen-editor-spec.html) reorder body corrected to{itemIds:[...]}; note added to frontend issue #753ADR added documenting the
journey_itemsordered join table,DocumentSummary-as-read-model, name-composition policy, multi-receiver canonical rule,ON DELETE SET NULLrationale, plain-text note, the two-write-path transition state, the EAGER-vs-LAZY collection rationale, the deferrable-unique-constraint choice, journey audit events being geschichte-scoped (documentIdnull), and thereceiverCountcardinality-disclosure lossiness notedocs/DEPLOYMENT.mdupdated with the sequenced pre-V73 deploy runbook (pg_dump → verify → pull → up--no-deps backend frontendtogether), the expected brief blank-journey window during simultaneous recreate, and the explicit "no schema down-migration; rollback = restore-from-dump (data-loss window) or roll-forward V74" sentence; pre-merge restore-rehearsal performed against a real prod dumpVerify the migration/constraint test class naming matches the repo's executed-in-CI Testcontainers convention (
*ITunder Failsafe vs*Testunder Surefire) so it actually runs in CI, not just locallygetByIdreturns aGeschichteViewwrapper for BOTH JOURNEY and GESCHICHTE types (uniform shape);authoris summarized to{id, displayName}only — testgetById_response_does_not_contain_author_email_or_groupsasserts the response has noauthor.email/author.groupsNote length soft cap of 5000 chars enforced on POST and PATCH; the 5001st char returns 400
VALIDATION_ERROR— testpost_returns400_when_note_exceeds_5000_charsandpatch_returns400_when_note_exceeds_5000_charsDocumentSummaryis built via a leandocumentService.getSummaryById(UUID)that skipstagService.resolveEffectiveColors— no wasted tag-color resolution per itemunique_constraint_is_deferrable_initially_deferred— raw SQL againstpg_constraint(condeferrableANDcondeferredboth true) asserting the flags directly (flush-order-proof guard for the deferrable property)Positive data-migration AC: a clean migration run on N seeded rows SUCCEEDS, drops
geschichten_documents, AND leavesjourney_itemscount == N (pairs with the negative parity-guard test to catch an early-placed guard that would abort a legitimate deploy)Cap measurement: the 100-item cap is
COUNT(*)-based, neverMAX(position)-derived — test: a journey with 99 rows butMAX(position)=2000still accepts the 100th appenddelete_returns404_when_itemAlreadyDeletedalso asserts NOJOURNEY_ITEM_REMOVEDaudit event fires on the 404 pathWhitespace-note test input includes embedded newlines (
"\n \n"), not only spaces — trims tonullThe 130-item grandfathered reorder test reuses the shared static
PostgreSQLContainer(not a standalone@SpringBootTest)Review Insights
Decided (round 6): GET response wrapper +
authorleak closedDecision (Markus, Nora — Decision Queue Option A):
getByIdreturns a single namedGeschichteViewresponse record for BOTH JOURNEY and GESCHICHTE types. IntroduceGeschichteViewin thegeschichte/package, assembled byGeschichteService.getById. It carries the existing entity fields PLUS the conditionalitems(@JsonInclude(NON_NULL), present only for JOURNEY), and thedocuments/personsfor both types.authoris summarized to{UUID id, String displayName}— the rawAppUsergraph (email, group memberships) is never serialized.GET /api/geschichten(list) keeps returning entities; only the detail endpoint changes.Rationale: Option B (discriminated union — raw entity for GESCHICHTE, wrapper for JOURNEY) leaves two divergent serialization shapes for one endpoint (a TS union the frontend must narrow) and lets the pre-existing
authorAppUser info-leak persist on the GESCHICHTE path. Option A costs ~30 lines of mapping and retypes every detail-page consumer once, but closes a real IDOR-adjacent info leak and gives a single TS type. Cost is paid once; divergence would be permanent.Breaking-change consequence (Markus): the GET response object changes from
GeschichtetoGeschichteViewfor ALL detail consumers, not just the JOURNEY path. Afternpm run generate:api, grep the frontend for.documents/.authoraccesses on the detail response (not only.items) and retype them. Add a regression AC:GETJOURNEY response contains noauthor.email/author.groups.Do NOT bolt a
@Transient List<JourneyItemView> itemsonto the entity — that reintroduces the serialization-leak / LazyInit risks round 5 eliminated. The wrapper is assembled by the service from the repository query.Decided (round 6): Server-side note length cap (5000 chars)
Decision (Leonie — Decision Queue Option A): enforce a soft cap of 5000 chars on
noteat POST and PATCH (after trim), returning 400VALIDATION_ERRORon overflow. Symmetric with the 100-item cap; bounds the reader's worst-case 320px layout once at the API instead of defending hostile/accidental walls of text in three render surfaces (reader, editor, editor-preview). One added validation branch + two tests.#753still appliesmax-w-prosefor in-bounds long notes.Decided (round 6): Lean read path for
DocumentSummaryDecision (Felix — Decision Queue Option A): add
documentService.getSummaryById(UUID)that returns the fieldsDocumentSummaryneeds WITHOUT runningtagService.resolveEffectiveColors(DocumentService:1004).getDocumentByIddoes a tag-color resolution pass the summary never reads — ×100 items per journey GET is wasted work that would surface as a Grafana trace anomaly. The new method keeps the domain boundary clean (DocumentService owns it) and keepsJourneyItemService.toSummaryhonest.JourneyItemService.toSummarycallsgetSummaryById, notgetDocumentById.Decided (round 6): Test-strategy reinforcements
JsonNullable+ the test's localnew ObjectMapper()(GeschichteControllerTest:47) are incompatible. The hand-rolled mapper lacksJsonNullableModule. The PATCH three-way test MUST post a raw JSON string through MockMvc (which uses the Spring-context, module-registered mapper) — it must NOT pre-serialize the body with the test class's localnew ObjectMapper(). Pinned for both the controller test and the QA strategy.reorder_swap_two_adjacent_items_succeedscan go green even under an immediate constraint if flush order happens not to collide. Addunique_constraint_is_deferrable_initially_deferred— a raw SQL assertion againstpg_constraint(condeferrableANDcondeferredboth true). This is the only guard the flush order cannot fake.geschichten_documentsintact") also passes if the guard is mis-placed before the INSERT (count 0 ≠ N → RAISE → table survives) — so it can't distinguish correct from broken-too-early. Add the positive companion: clean run on N seeded rows SUCCEEDS, dropsgeschichten_documents, andjourney_itemscount == N. The early-guard placement fails the positive test.COUNT(*), neverMAX(position)-derived. Deletions leave permanent position gaps (delete #50 in a 100-item journey → ceiling 1010 at 99 rows); aMAX(position)/10cap check would falsely reject appends below 100 items. Test: 99 rows withMAX(position)=2000still accepts the 100th append.VALIDATION_ERROR(reorder set-mismatch) ANDJOURNEY_ITEM_POSITION_CONFLICT(409) as the same human cause — "this journey was changed elsewhere — reload" — a single recovery toast, not two divergent flows.u. a./receiverCountis localized prose, not a hardcoded German abbreviation — #753 mapsreceiverCountthrough Paraglide (de/en/es), never concatenates" u. a."literally.Decided (round 6): Operational notes
afterCommitstraddles the container recreate may be lost (content is safe — it committed; the audit trail is best-effort,writeLogcatches and logs). One sentence indocs/DEPLOYMENT.md.executeInTransaction=false/ no callback splits the migration; paste the Flyway log line showing it committed atomically.Decided: Service extraction
Decision: Extract
JourneyItemService.GeschichteServiceis already ~200 lines; adding item CRUD would push it past 300. Pattern:AnnotationServicefor the annotation sub-domain underdocument/.GeschichteControllercallsjourneyItemServicedirectly —GeschichteServiceis not the intermediary (Spring Framework 7 prohibits constructor-injection cycles). The append method is namedappend(notcreate) to match AC vocabulary.Decided (CORRECTED round 4):
DocumentSummaryfinal shapeFinal shape:
record DocumentSummary(UUID id, String title, LocalDate documentDate, LocalDate documentDateEnd, DatePrecision datePrecision, String senderName, String receiverName, Integer receiverCount) {}— a Java record (pure value object, no JPA lifecycle), owned in thegeschichte/package (it is the geschichte domain's read-model view of a document).DatePrecisionCORRECTION (bug fix).DatePrecision.javahas seven values:DAY, MONTH, SEASON, YEAR, RANGE, APPROX, UNKNOWN— there is no "separator" value.SEASON("Frühjahr 1922") andAPPROX("um 1938") are real in a 1899–1950 letter archive (verbatim mirror of the import normalizer, ADR-025). The summary copies the enum value through unchanged; an AC pins thatSEASON/APPROXround-trip without defaulting toUNKNOWN. The reader spec (#753) must format all seven, not five.fileKeyDROPPED. The field does not exist onDocument(entity hasfilePath,thumbnailKey,fileHash). The frontend fetches scans via the authenticated proxyGET /api/documents/{id}/fileusingdocument.id. Embedding a storage key would leak the internal S3 object layout. The reader needs nothing beyondid.documentDateEnd+datePrecisionADDED.datePrecisionmaps from the entity fieldmetaDatePrecision(columnmeta_date_precision) —doc.getDatePrecision()does not compile. Contract:documentDateis always the range start (metaDate),documentDateEndthe end (metaDateEnd) — never swapped.senderName+receiverNameADDED as two separate fields. Two fields give the 320px reader independent reflow control. Nullable; fall back to raw attribution (senderText/receiverText) when no Person is linked.receiverCount(round-4 multi-receiver decision, round-5 contract tightening).Document.receiversis@ManyToMany.receiverName= first canonical receiver;receiverCount= total linked receivers. Round-5 (Leonie):receiverCountis non-null for any document-bearing item —0when no receiver is linked (thenreceiverName == null),>=1otherwise. This removes the "nullable Integer that's sometimes absent" ambiguity: the reader's logic is unambiguous (receiverCount > 1→ " u. a.";receiverName == null→ omit the "an Y" clause). The lossiness (only the first canonical name carried) and the new cardinality disclosure (receiverCounttells readers "this letter had N recipients", a new disclosure vs. the olddocumentsarray — intended for "u. a.") are recorded in the ADR.Personexposes onlytitle, firstName, lastName, nameAliases.senderName/receiverName= linked Person'sfirstName + lastNametrimmed; else the rawsenderText/receiverText; else null. "First canonical receiver" = first receiver by deterministic sort (lastName, then firstName) when ≥1 Person is linked.JourneyItemServiceis the single owner — a privatetoSummary(Document)helper (≤20 lines), unit-testable with a mockedDocumentService. If a name-display helper exists in the person domain, call it viaPersonService; do NOT duplicatefirstName + lastName. Do NOT put@Transient getSummary()on the entity.JourneyItem.documentis a JPA@ManyToOne(fetch = FetchType.LAZY) @JsonIgnore(for the FK +ON DELETE SET NULL); the summary is assembled viadocumentService.getDocumentById(item.getDocument().getId())(id only — the LAZY proxy is never field-initialized). See round-5 N+1 note below.Decided (round 5): Read-model shape —
JourneyItemViewresponse recordDecision: introduce a
JourneyItemViewresponse record{ UUID id, int position, DocumentSummary document, String note }in thegeschichte/package, assembled byJourneyItemService. The GETitemsfield and the POST/reorder responses all returnJourneyItemView— never the JPAJourneyItementity (which has ageschichteback-reference that would leak / hitLazyInitializationException, and a@JsonIgnoreddocument).Decision (Felix's Option B — chosen for explicitness): do NOT serialize a mapped
@OneToManycollection.JourneyItemServicequeries items viaJourneyItemRepository.findByGeschichteIdOrderByPosition(id)and assembles theList<JourneyItemView>. Rationale: matches the "DocumentSummarybuilt by service" decision, gives a fully explicit read model with zero serialization-leak risk, and avoids the LazyInit trap of a mapped collection touched outside the read transaction. Ordering lives in the repository query (OrderByPosition); cascade/ON DELETElives in the migration FKs. If a mapped@OneToManyis added later for any reason it MUST be@JsonIgnored — but the read model never depends on it.N+1 guard (Felix):
JourneyItem.documentdefaults to EAGER for@ManyToOne; make it explicit@ManyToOne(fetch = FetchType.LAZY) @JsonIgnore. The service builds the summary fromgetDocument().getId()only, thendocumentService.getDocumentById(id)— no association traversal that would fire one SELECT per item.Decided: Note rendering and HTML policy
Decision: Plain text, stored verbatim, escaped only at render. No HTML rendering, no
BODY_SANITIZER, no TipTap.@Column(columnDefinition = "TEXT"). Stored exactly as received (including<script>bytes), returned verbatim by GET — escaping is the frontend's job via{note}(never{@html}). Rejecting on<would break "5 < 10"; stripping silently mutates curator input. Backend regression test pins storage AND assertsContent-Type: application/json(round-5 Nora — prevents a mis-settext/htmlturning verbatim storage into reflected stored XSS). The XSS burden moves entirely to the frontend{note}binding — a frontend test in #753 must assert a note containing<img src=x onerror=alert(1)>renders as visible literal text and never executes.Round-5 (Nora): the same verbatim-text posture applies to
senderName/receiverName— they fall back to unsanitized historical-import free text (senderText/receiverText), an attacker-influenced-via-import surface the curator never typed. The #753 frontend-XSS literal-text test must coversenderName/receiverName, not justnote. Noted on #753.Whitespace handling: notes are trimmed; blank-after-trim is stored as
null(POST and PATCH). Consequence: an item withnote == null && document == nullcannot exist — the reader needs zero defensive empty-state handling for interludes.Decided: GET response for non-JOURNEY type, and DRAFT JOURNEY read path
Decision:
itemsabsent for non-JOURNEY;documentsretained for GESCHICHTE. Discriminated union bytypevia@JsonInclude(NON_NULL)on the response record'sitemsfield. Items are queried LAZILY by the service (see Read-model shape) — onlygetByIdloads them, notGET /geschichten. ConsequentlygetByIdmust be@Transactional(readOnly=true)to assemble the read model inside one transaction.Round-5 (Markus):
@Transactional(readOnly=true)must WRAP the existing DRAFT-visibility guard, not bypass it.GeschichteService.getById(lines 63–73) throwsGESCHICHTE_NOT_FOUNDfor DRAFT stories when the caller lacksBLOG_WRITE. The transaction spans the whole method; theitemsload happens after the guard passes (don't load the collection for a request about to 404). Curators build journeys in DRAFT, so two realistic editor-path tests are required:getById_returns_items_for_draft_journey_when_user_has_BLOG_WRITEandgetById_throws_NOT_FOUND_for_draft_journey_when_user_lacks_BLOG_WRITE.ADR consequence (Markus): record WHY
persons/documentsare EAGER (list-endpoint N+1 avoidance) anditemsis deliberately LAZY — so a future maintainer doesn't "fix the inconsistency" by makingitemsEAGER and reintroduce the load-everything regression.Decided: Two-write-path guard
Decision:
GeschichteService.update()rejectsdocumentIdswhentype == JOURNEY. During the transition, JOURNEY documents live injourney_itemswhile the entity retains@ManyToMany documents(used by STORY type).personIdsstay shared (no guard). The guard readsg.getType()from the entity —typeis NOT aGeschichteUpdateDTOfield (round-5 Felix: prevents flipping a story's type via PATCH). ExistingGeschichteService*tests callingupdate()withdocumentIdson a story the migration retypes to JOURNEY will fail — fix as a regression AC. Recorded in the ADR.Decided: PATCH mutable fields; attach-document non-goal
Decision:
noteonly viaJsonNullable<String>. A plainOptional<String>cannot distinguish absent from explicit null under Spring'sJdk8Module. Addopenapi-jackson-nullableand registerJsonNullableModule(its own atomic commit before the PATCH DTO work).isPresent()distinguishes absent from present;.get()==nulldistinguishes explicit null. Three-way test against the real deserializer (MockMvc).Round-5 (Elicit) — DECIDED: attaching a document to an existing note-only interlude is a NON-GOAL for this issue. PATCH stays note-only;
documentIdis immutable. A curator who wants to turn an interlude into a document item deletes and re-adds. Rationale: keeps PATCH and its test matrix small; the friction (lost note/position) is rare and acceptable for the first cut. Stated as an explicit non-goal in the PATCH contract.Decided: PUT /reorder response, size cap, grandfathered journeys
Decision: Return the updated items array (
List<JourneyItemView>), enabling optimistic drag-reorder reconciliation without a follow-up GET. Tests assert the body, not just status. CapitemIds.size()before the set-equality check (cheap memory-DoS block).Round-5 (Elicit) — DECIDED: the reorder size cap is
max(MAX_ITEMS, currentItemCount), not a flat 100. A grandfathered 130-item journey (migrated over the cap) must stay fully reorderable — a flat-100 cap would reject the legitimate 130-id full-set reorder and contradict the round-4 "grandfathered journeys remain editable" decision. Append still rejects past 100 (a 130-item journey cannot grow). ACreorder_of_grandfathered_over_cap_journey_succeeds.Decided (round 5): Reorder position writes — deferrable unique constraint
Round-5 (Sara) — DECIDED: make
uq_journey_items_geschichte_positionDEFERRABLE INITIALLY DEFERRED(Sara's Option A). With an immediate unique constraint, a valid swap (item-B → 10 while item-A still holds 10 until flush) transiently collides mid-flush and throwsDataIntegrityViolationExceptionon a perfectly valid reorder — non-deterministic, flush-order dependent. A deferrable constraint checks once at commit: the whole 10/20/30 reassignment commits atomically, swaps never transiently collide, and a genuine duplicate (concurrent append racing the reorder) still fails at commit and gets the 409 remap. This is the textbook Postgres pattern for reassigning an ordered column. Rejected: Option B (two-phase +1000 offset — ugly, 2× UPDATEs, still racy) and Option C (immediate + frontend retry — valid reorders intermittently 409, bad curator UX). New-pattern cost (no repo precedent, slight planner cost) is negligible at family-archive scale. ACreorder_swap_two_adjacent_items_succeedsgoes red with an immediate constraint and green with the deferrable one.Decided: Position uniqueness AND race HTTP contract
Decision:
DEFERRABLE INITIALLY DEFERRED UNIQUE(geschichte_id, position)(named) plusCHECK (position > 0).SELECT MAX(position)+10inside@Transactionalis not fully race-free; the unique constraint catches the tie.Decision: the race must surface as 409, not 400 — on BOTH write paths.
GlobalExceptionHandler.handleDataIntegrityViolationmaps every integrity violation to 400VALIDATION_ERROR, which blames the curator for a server-side collision. CatchDataIntegrityViolationException, inspect the constraint name, rethrow asDomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, ...)→ HTTP 409, message "Another change reordered this journey — please retry."Round-5 (Sara): the 409 remap covers
appendANDreorder— both writepositionand hit the same constraint. Extract the catch-and-remap into a privateJourneyItemServicehelper both methods call — one place to test and maintain. ACreorder_conflict_surfaces_as_409.Round-5 (Nora): pin the constraint name as a shared constant. Name the constraint explicitly in the migration (
CONSTRAINT uq_journey_items_geschichte_position UNIQUE (...)) and match the service's remap branch against a named constant (e.g.JourneyItem.UQ_POSITION_CONSTRAINT = "uq_journey_items_geschichte_position") referenced by both. If Postgres auto-names the constraint (inlineUNIQUE(...)withoutCONSTRAINT name) or the literal drifts, theif (constraintName.equals(...))branch silently fails open and the curator sees the misleading 400 — a silent regression no happy-path test catches. AC: the 409-remap test asserts the named path; a renamed-constraint regression goes red.Test isolation (Sara): the 409-remap test is a service/slice test with real
@Transactionaland must NOT be a member of the non-@TransactionalJourneyItemConstraintsTest(raw-insert jdbcTemplate tests) — aDataIntegrityViolationExceptioninside a class-level@Transactionalmarks the tx rollback-only and can cascade intoTransactionSystemExceptionon teardown.Decided: PATCH note:null on document-less item
Decision: Return 400. Consistent with the POST invariant. Auto-delete would be silent data loss; a tombstone forces reader-side null handling.
Decided: DELETE idempotency
Round-5 (Elicit) — DECIDED:
DELETE /items/{itemId}on an already-deleted item returns 404JOURNEY_ITEM_NOT_FOUND(not 204). Consistent with the IDOR contract (findByIdAndGeschichteIdreturns empty → notFound). For a drag-heavy editor with optimistic local state, the frontend treats "already gone" as success client-side and suppresses the toast. ACdelete_returns404_when_itemAlreadyDeleted.Decided: ErrorCodes
Decision: Add
JOURNEY_ITEM_NOT_FOUND(do not reuseGESCHICHTE_NOT_FOUND) andJOURNEY_ITEM_POSITION_CONFLICT(409). Each in all four places:ErrorCode.java,errors.ts, all threemessages/*.json. Keys:error_journey_item_not_found,error_journey_item_position_conflict. The reorder set-mismatch 400 reusesVALIDATION_ERRORbut with a distinguishable message so #753 can show "this journey was changed elsewhere — reload" rather than a generic validation error (round-5 Elicit; concurrent-edit recovery path).Decided: POST contracts — non-JOURNEY, bad documentId, max-items cap
Decision: POST to a non-JOURNEY Geschichte returns 400 (
VALIDATION_ERROR). Verify the parent exists ANDtype == JOURNEYbefore creating.POST with a non-existent
documentIdreturns 404DOCUMENT_NOT_FOUND— falls out ofdocumentService.getDocumentById()(precedent:GeschichteService.resolveDocumentslines 179–186 already loops it), semantically honest, pinned by AC so the frontend handles 404 not 400.Soft cap of 100 items per journey, enforced on POST (the 101st returns 400
VALIDATION_ERROR). Pre-existing journeys migrated over the cap are grandfathered — editable (incl. reorder, via themax(100, currentCount)reorder cap) except adding items. Cap test seeds 100 items via fixture, not 100 HTTP calls.Decided (round 5): Audit — wiring against the real AuditService
Round-5 (Markus): the audit decision is unbuildable as previously written and is now corrected.
AuditServicesignatures arelog(AuditKind, UUID actorId, UUID documentId, Map payload)andlogAfterCommit(...)— the only entity dimension isdocumentId(AuditLog.javaline 40, nullable). A reorder is not about a document; an interlude add has no document.AuditKindvalues —JOURNEY_ITEM_ADDED,JOURNEY_ITEM_REMOVED,JOURNEY_ITEMS_REORDERED— with Javadoc payload docs matching house style, as their own atomic commit before the service work (same discipline as the jackson-nullable commit). The audit AC becomes:logAfterCommit(<new kind>, actorId, null, Map.of("geschichteId", id, …))—documentIdarg is null;geschichteIdlives in the jsonb payload. JamminggeschichteIdinto thedocumentIdslot would corrupt every document-scoped audit query (AuditLogQueryServicefilters bydocumentId).JOURNEY_ITEMS_REORDEREDtoROLLUP_ELIGIBLE; leave add/remove out. Drag-reordering fires many reorders per editing session (a 30-drop session → 30 rows); rollup keeps the chronik readable. Per-reorder forensic granularity is not worth flooding the family chronik for a curation tool. Add/remove are discrete, low-frequency, individually meaningful — not rolled up.documentIddeliberately null) — so a future maintainer doesn't "fix" the null by back-filling a document id. NewAuditKindvalues are a domain enum addition →docs/GLOSSARY.md+ the audit ADR.Decided: JOURNEY intro
bodyformatDecision: Out of scope here, recorded. When the editor issue lands, JOURNEY intro should be plaintext (consistent with "no TipTap for journeys"). This issue does not change
body.Decided: Migration drop strategy
Decision: Single PR, DROP gated by an in-SQL row-count parity guard. Flyway is forward-only and runs automatically on prod boot on a single VPS with no staging. One PR for schema cleanliness; the drop is self-protecting —
DO $$ BEGIN IF (SELECT count(*) FROM journey_items) <> (SELECT count(*) FROM geschichten_documents) THEN RAISE EXCEPTION ...; END IF; END $$;aborts before the DROP if counts disagree. Single-transaction is load-bearing: Postgres transactional DDL + Flyway's per-migration transaction meansRAISE EXCEPTIONrolls back the DROP. A SQL comment forbidsCONCURRENTLY/non-transactional; a test seeds a deliberate mismatch and assertsgeschichten_documentssurvives.Round-5 (Tobias): pin the intra-file statement order — (1) CREATE TABLE → (2) INSERT … SELECT FROM geschichten_documents → (3)
DO $$parity guard → (4) DROP TABLE. The guard sits textually between INSERT and DROP; reordering the guard above the INSERT (guards "feel like" preconditions) compares 0 vs N and aborts every deploy or passes vacuously. AC pins the order.Decided: Document-read access model (security side channel)
Decision: Document reads are uniform across authenticated users —
SecurityConfigline 77/120 isanyRequest().authenticated();DocumentControllerGET /{id}(line 136) andGET /{id}/file(line 86) carry no@RequirePermission. EmbeddingDocumentSummaryin a published journey discloses nothing a reader couldn't already fetch. But this rests on an implicit default, not an explicit policy — a security-assumption regression test (aREAD_ALL-only user GETs a published JOURNEY and seesitems[].document) codifies it and goes red the day document reads are scoped, forcing the drafted redaction approach ("[Dokument nicht verfügbar]" per-item, not whole-journey 403) to be implemented rather than forgotten.Resolved contradiction: reorder body contract
This issue's
{ "itemIds": [...] }is authoritative. The editor design spec (lesereisen-editor-spec.htmllines 523/762/783) currently says[{id, position}]— correct it to{itemIds:[...]}as part of this PR, with a note on frontend issue #753.Corrected: coverage gate number
The enforced JaCoCo gate is
<minimum>0.77</minimum>(backend/pom.xmlline 349, ratcheted per #496) — not 88%. Round-5 (Markus):backend/CLAUDE.mdTesting section still says "88% branch coverage" — fix it to0.77in this PR (the number is load-bearing for "do not panic-pad tests"). The ~14–16-branch estimate still holds and every branch needs an explicit test.Test strategy (layer assignment + container sharing)
toSummary;@WebMvcTestslice — 401/403 per endpoint,jsonPath($.items).doesNotExist(),@MockitoBeanwiring; Testcontainers integration — every FK/constraint/cascade/data-migration AC plus theLazyInitializationException-free GET and the DRAFT-JOURNEY read tests.JourneyItemConstraintsTestsharing a singlestatic PostgreSQLContainer(~6 methods) — saves ~60–80s CI per run. The 409-remap service test is NOT a member of this class (it needs real@Transactional).@Transactional(jdbcTemplate + explicit@Sqlteardown) — aDataIntegrityViolationExceptioninside a class-level@Transactionaltest marks the tx rollback-only and cascades intoTransactionSystemExceptionon teardown.positionset =={10,20,…,10*n}; never couple a specific document to a specific position (UUID order is arbitrary).*ITunder Failsafe vs*Testunder Surefire) — otherwise the highest-risk test passes locally and silently never runs in CI. PR checklist: paste the CI log line showing the migration test ran and that the seed@Sqlran before Flyway.Confirmed risks
DEFERRABLE INITIALLY DEFERRED. A swap is checked at commit, not mid-flush.reorder_swap_two_adjacent_items_succeedsis the canary.AuditService— needs three newAuditKindvalues anddocumentId == nullwithgeschichteIdin the payload; jamming geschichteId intodocumentIdcorrupts document-scoped audit queries.@Transactional(readOnly=true)must wrap the existing DRAFT-hiding guard; items load only after the guard passes. Two editor-path tests required.@ManyToOne document— makeJourneyItem.documentexplicitly LAZY; build the summary from the id only.authorAppUser graph leaked on the detail GET — closed by summarizingauthorto{id, displayName}inGeschichteView(round 6); a regression AC asserts noauthor.email/author.groupsships.JourneyItemView— would leak thegeschichteback-ref / hit LazyInit; return the assembled view record everywhere.max(100, currentCount).docker compose up -d --buildis one command. Mitigations: in-SQL single-transaction parity guard with pinned statement order; data-migration integration test; sequenceddocs/DEPLOYMENT.mdrunbook (pg_dump→ verify →pull→up -d --no-deps backend frontendtogether, with the brief blank-journey window noted and thepg_restorecommand recorded); the explicit "no schema down-migration; rollback = restore-from-dump (data-loss window) or roll-forward V74" sentence; pre-merge restore-rehearsal against a real prod dump (covers NULLsenderText, multi-receiver docs, legacy collation synthetic data misses).@Sqlagainstgeschichten_documentsbefore Flyway runs.postgres:16-alpine— verify class naming matches the CI-executed convention; verify the seed@Sqlran before Flyway (not vacuously after a drop).note/senderName/receiverNameverbatim → frontend XSS half — the verbatim decision is only safe because the reader renders text. #753 frontend tests (<img onerror>renders as literal text, noalert) must cover note AND the two name fields. Backend GET assertsContent-Type: application/json.SEASON/APPROXmis-render — a five-case date formatter in #753 would silently mis-render the two omitted precisions for exactly this archive's audience. #753 hand-off: "Frühjahr/Sommer/Herbst/Winter YYYY" (SEASON) and "um YYYY" (APPROX) are required acceptance, not optional; falling back to rawYYYY-MM-DDloses the historical hedging the archive exists to preserve.senderName == null && receiverName == nullis real (letter with no linked Person and nosenderText/receiverText). #753 hand-off: render the document title only, no attribution line — never "von an ".receiverCountcardinality disclosure — a new (intended) disclosure vs. the olddocumentsarray; record in the ADR lossiness note.white-space: pre-line(notpre/pre-wrap) —prewould prevent wrapping on 320px (horizontal scroll, Critical mobile failure) and preserve accidental trailing spaces. Pinned; don't let it drift in #753.itemIdsset includes B's gone item → set-equality 400. Correct behavior; #753 needs an honest "this journey was changed elsewhere — reload" recovery message (distinguishable from a malformed request).findByIdAndGeschichteIdmandatory for PATCH/DELETE;PUT /reorderIDOR closed by set-equality. Both IDOR tests authenticate as a valid BLOG_WRITE user.Set<UUID>equality.notethree-way semantics fails with plainOptional<String>under default Jackson —JsonNullablerequired, test hits the real deserializer.GeschichteControllerTestsilent breakage — add@MockitoBean JourneyItemServicebefore any new tests.documents→itemsin the JOURNEY GET response; Docker Compose restarts frontend + backend together (no rolling-restart skew). Regenerated*.d.tscommitted in this PR so CI builds against the new contract.GeschichteTypeenum,JourneyItementity, and migrations beyond V71 do not yet exist inmain. The branch cannot be cut and TDD red cannot start until the predecessor PR merges and CI is green; the Flyway version (V73+) is assigned at branch-cut time.