feat(lesereisen): data model + Flyway migration — GeschichteType, JourneyItem, migrate geschichten_documents #750
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
Extend the
Geschichteentity to support Reading Journeys by adding a type discriminator and replacing the unorderedSet<Document>with an orderedJourneyItemsequence. This is the foundation all other Lesereisen issues build on.Background
A Lesereise (Reading Journey) is a
Geschichtewithtype = JOURNEY. Its primary content is an ordered sequence of letters with optional curator notes. Design specs:docs/specs/lesereisen-reader-spec.htmlanddocs/specs/lesereisen-editor-spec.html.Tasks
New enum:
GeschichteTypeAdd to
Geschichteas a non-null columntype, defaultSTORY.New entity:
JourneyItemdocument_id = null→ pure interlude note (context between letters, no document);notemust be non-null (enforced by CHECK constraint)document_id→ plain letter entry, no notepositionis the sort key; gaps are fine. Duplicate positions within one journey are allowed with documented undefined-order semantics (see Review Insights — round 4)noteis plain text (not HTML) — render with{note}in Svelte, no sanitization requireddocumentfield: annotate with@JsonIgnore; exposedocumentId(UUID) only in the API response — see serialization decision below@Schema(requiredMode = REQUIRED):id,positionare always populated →REQUIRED.documentIdandnoteare nullable → NOT required (omitrequiredModeso the generated TS types them as optional). This drives correct TypeScript optionality for the follow-on frontend.Place
JourneyItemingeschichte/journeyitem/sub-package, consistent withdocument/transcription/anddocument/annotation/patterns.JPA mapping on
Geschichte.items— LAZY with explicit init (decision refined in round 4, see Review Insights)cascade = ALL, orphanRemoval = trueis mandatory — deleting aGeschichtemust cascade to itsJourneyItemrows.FetchType.LAZY+@Transactional(readOnly = true)onGeschichteService.getById()plus an explicitgetItems().size()(orHibernate.initialize) inside the transaction —@Transactionalalone does NOT hydrate a LAZY collection (it inits on access), andopen-in-view: falsemeans there is no session at serialization time. Without the explicit touch,GET /api/geschichten/{id}throwsLazyInitializationException→ HTTP 500. This was the round-4 blocker.@OrderBy("position ASC")— not@OrderColumn— becausepositionis a domain-meaningful column.@Builder.Defaultwithnew ArrayList<>()(notHashSet), preserving ordered list semantics.list()returns aGeschichteSummaryprojection (added round 4; field set + query mechanism locked round 5)GeschichteService.list()must NOT return fullGeschichteentities — withopen-in-view: falseandlist()being non-transactional, Jackson serializinggetItems()(a LAZY collection) 500s the moment any journey exists. The fix is a list-specific projection that never carriesitems.GeschichteSummarycarries exactlyid, title, status, type, author, publishedAt, body. It does NOT carryitemsand does NOT carrypersons. Rationale: the live grid card (geschichten/+page.svelte:131–146) renderstitle,author(byline),publishedAt, and abodyexcerpt — and never readspersons. The round-4 draft list (id, title, status, type, publishedAt, persons + any other) both dropped the byline/excerpt (a hidden visible regression) AND carried an unusedpersonsassociation. Drop the "(and any other fields the card grid already renders)" parenthetical — it is untestable.GeschichteService.list()returnsList<GeschichteSummary>(orPage<GeschichteSummary>mirroring the current pagination shape).interface GeschichteSummary { UUID getId(); String getTitle(); ... }) returned from an explicit@QuerywithORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC. This replaces theSpecification-drivenfindAllon the list path and sidesteps theGeschichteSpecifications.orderByDisplayDateDesc()/ constructor-expression composition problem entirely (that spec'squery.orderBy(...)+ COUNT special-case does not compose cleanly with a projection query). KeeporderByDisplayDateDesconly oncountPublished/any filter spec you retain.list()computesGeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED(line 81) and applieshasStatus(effective)+hasAuthor(authorId)(line 84). The projection query must apply the identical clamp. If the implementer writes the projection fresh and forgets this, DRAFT journeys leak into the public grid — a real authz regression.hasAllPersons(person-filter) MUST survive the rewrite (round 5): person-filtering the grid is shipped behaviour. The projection query must still accept and apply the person filter (reimplement against the projection query). OnlyhasDocumentis removed.personsCartesian concern for the grid entirely — the projection selects exactly what it needs.PersonSummaryDTOprecedent.npm run generate:apiproduces a second TS type; thegeschichten/+page.server.ts/+page.svelteconsumers readGeschichteSummary[]for the grid.dashboard/StatsService.java:19is the only external caller and usesgeschichteService.countPublished()→geschichteRepository.count(spec), which never touchesitemsor the projection. Safe.Changes to
GeschichteentitySet<Document> documents(backed bygeschichten_documents)List<JourneyItem> itemsordered byposition(LAZY mapping above)GeschichteType typeSet<Person> persons(viageschichten_persons) — intentionally NOT removed; a JOURNEY carries a denormalizedpersonsset alongside itsitemsbodyfield stays — for STORY it is the rich-text article; for JOURNEY it is an optional intro/prefaceService / DTO / Controller changes (compile-breaking, must ship with this issue)
@Transactional(readOnly = true)toGeschichteService.getById()and an explicitgetItems().size()init inside the transaction body (required by the LAZY +open-in-view:falsedecision soitemshydrate before serialization)GeschichteService.list()to return aGeschichteSummaryprojection (never serializesitems) — see section above; carry the DRAFT clamp +hasAllPersonsfilterGeschichteUpdateDTO.documentIdsfieldGeschichteService.resolveDocuments()methodresolveDocuments()fromGeschichteService.create()andupdate()GeschichteSpecifications.hasDocument()and thedocumentIdparameter fromGeschichteService.list()— add// TODO(lesereisen-editor): restore document filter via journey_items joincomment at the removal site. KeephasStatus/hasAuthor/hasAllPersons— reimplement them for the projection query.@RequestParam(required = false) UUID documentIdfromGeschichteController.list()(line 36) and the corresponding argument passed togeschichteService.list(...)(line 41) — leaving it would reference a removed service parameter (compile error)GeschichteService.javaimports:import ...document.Documentbecomes unused — remove it. Keepimport ...document.DocumentServiceand thedocumentServicefield with a// Reserved for lesereisen-editorcomment (the editor issue resolves item documents through it).LinkedHashSetstays (still used byresolvePersons).GeschichteServicemay callDocumentService(notDocumentRepositorydirectly) when resolving documents forJourneyItemcreation in later issues. Security boundary:JourneyItem.documentIdwrites must always go throughDocumentService.getDocumentById(id)(enforces existence/scope) — never a raw client-supplied UUID persisted directly into the FK. This preserves the boundary the removedresolveDocuments()enforced. The follow-on reader must reach DRAFT JOURNEYs only viagetById()(NOT_FOUND guard) — no/journeys/{id}endpoint that skips the check.Frontend build-integrity changes (must ship with this issue)
After
npm run generate:api, thedocumentIdquery param disappears from the generated OpenAPI types, turning these into compile-time TS errors that breaknpm run checkon this PR — so they cannot be deferred to the follow-on. All four sites named precisely (round 5):frontend/src/routes/geschichten/+page.server.ts— (site 1) remove the?documentId=query read and thedocumentIdentry in theapi.GET('/api/geschichten', { params: { query } })call; drop thedocumentFilterreturned from the load. Consume the newGeschichteSummary[]shape for the grid.frontend/src/routes/geschichten/new/+page.server.ts— (site 2) remove the?documentId=pre-fetch ofGET /api/documents/{id}frontend/src/routes/geschichten/+page.svelte— (site 3, real compile error) remove thehasFiltersderived's read ofdata.documentFilterat line 14 (hasFilters = ... || !!data.documentFilter) — once the load stops returningdocumentFilter, this is a TS error. (site 4, dead-but-harmless) removeurl.searchParams.delete('documentId')at line 19 insiderebuildUrl()—URLSearchParams.delete()accepts any string so it is not a compile error; remove for cleanliness. The surroundingrebuildUrl()(called byclearAll/addPerson/removePerson) stays — person filtering is live.frontend/src/routes/geschichten/[id]/+page.svelte— change the related-letters block from{#each g.documents as d (d.id)}to{#each g.items as item (item.id)}. The each-key identity changes fromdocument.idtojourneyItem.id. Theitemspayload exposes ONLYdocumentId(thedocumentobject is@JsonIgnore'd) — there is no document title or date available. Render the link with a generic localized label (new i18n key, see below), withdocumentIdonly in thehref, and only whenitem.documentIdis set. WhendocumentIdis null butnotepresent, render the note as plain text in a<p class="whitespace-pre-line">(interlude items are first-class;whitespace-pre-lineprevents run-on rendering of hand-authored multi-paragraph notes). One{#if}.geschichten_journey_letter_linktomessages/{de,en,es}.json—Brief öffnen/Open letter/Abrir carta. The stub link text must be descriptive (WCAG 2.4.4 Link Purpose) — never a bare UUID or empty<a>.frontend/src/routes/geschichten/[id]/edit/GeschichteEditor.svelte—geschichte?.documentsbecomesundefinedpost-regen so line 48's optional chain always falls toinitialDocuments(editor silently loses persisted linkage). Acceptable as a deferred stub; confirmedit/page.svelte.test.tsdoes not assert document round-trip. Verifynpm run checkpasses.frontend/src/routes/geschichten/[id]/page.svelte.test.ts— thedocuments: [...]fixture and the two tests (omits the documents section when there are no linked documents,renders the documents section when there are linked documents) referenceg.documents. Rewrite both tests tog.items(cheap, keeps render coverage through the stub period; must include an interlude/note-only item asserting it renders without a broken link). Assert the document-backed link viagetByRole('link', { name: ... })(the localized label) — not just that an<a>exists — so a bare-UUID/empty-text regression fails at the test layer.frontend/src/routes/geschichten/[id]/edit/page.svelte.test.ts— update thedocuments: []fixture; confirm no document round-trip assertion remains.This is the minimum to keep the build green, NOT the full reader/editor redesign (that is the follow-on frontend issue).
Accepted degraded UX during the stub period (round 5 — signed-off scope boundary)
This issue ships a deliberate, temporary, visible degradation — stated here so it is a signed-off boundary, not a review surprise:
publishedAt, and thebodyexcerpt (the projection field set guarantees this — the excerpt is load-bearing scent-of-information for the reader index, not decorative)./geschichten/[id]related-letters section degrades: document-backed items render as a generic localized "Open letter" link (no per-document title/date until the follow-on restores them); interlude (note-only) items render as plain text with preserved line breaks. This affects all Geschichten, including existing published family STORIES (they render the related-letters block too) — their rich title/date links degrade to generic links until the follow-on. Data is preserved initems.relates-to/blocking dependency so the degraded stub does not silently become permanent.JourneyItemsub-package contents (this issue only)geschichte/journeyitem/needs exactly:JourneyItem.java,JourneyItemRepository.java. No service or controller in this issue — those come later. Do not add anything not required by this issue's AC. If the dual-collection shape (personsSet +itemsList on one entity) causes confusion, add a short sub-packageREADME.mdexplaining it.Flyway migration
Use migration version V72 (latest committed are V70, V71). Single file:
V72__add_journey_items_migrate_geschichten_documents.sql.type VARCHAR(50) DEFAULT 'STORY' NOT NULLtogeschichtenjourney_itemstable (including CHECK constraint and FK cascade rules above)CREATE INDEX idx_journey_items_geschichte_position ON journey_items (geschichte_id, position ASC)geschichten_documentsrows →journey_itemswith positions initialised at multiples of 1000 (1000, 2000, 3000…), ordered bydocuments.document_date ASC NULLS LAST, documents.id ASCpergeschichte_id(tiebreaker onidensures deterministic ordering when dates are equal or null). UseSELECT DISTINCTon(geschichte_id, document_id)in the source query to guard against any duplicate junction rows producing duplicate journey items.geschichten_documentsUse a single migration file — the codebase has no precedent for split DDL/DML migrations and the transaction boundary is correct in Flyway's single-file model (Postgres makes
DROP+CREATE+INSERT...SELECTatomic in one transaction; any CHECK violation rolls the whole thing back cleanly).No
CREATE INDEX CONCURRENTLYneeded — thejourney_itemstable is empty at migration time (newly created), so index builds instantly with zero locking impact. (CONCURRENTLY cannot run inside the Flyway transaction anyway and would break the single-file atomicity.)Deploy checklist (round 4; env-var quoting fixed round 5 — DevOps)
$POSTGRES_USER/$POSTGRES_DBare set inside the container, not on the host shell. Wrap each command insh -c '...'so the vars expand inside the container. Single-line commands, in order:docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT COUNT(*) FROM geschichten_documents;"'pg_dumpone-liner (alsosh -c-wrapped) from the migration comment above.docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT COUNT(*) FROM journey_items;"'— assert it equals the pre-check count (each junction row maps 1:1 to a journey_item).docker-compose.ymlhas a healthcheck and the reverse proxy gates on it (depends_on: service_healthy), so a boot-validation mismatch surfaces as a failed deploy (the new image refuses to come up before serving traffic), not 502s on a swapped-out healthy container. The PR must paste the actualhealthcheck:block + the proxy'sdepends_onfromdocker-compose.ymlas evidence — or note its absence as a flagged pre-existing gap (do not block this issue on it).pg_dumpof production into a throwawaypostgres:16-alpinecontainer, run the backend's Flyway against it, then paste the three verification queries' output into the PR. A hand-built fixture won't carry the duplicate / null-date edge cases theSELECT DISTINCTdedup and thedocument_date ASC NULLS LAST, id ASCtiebreaker exist for — only real production junction data exercises them.Acceptance criteria
GeschichteTypeenum exists withSTORYandJOURNEYvaluesJourneyItementity andjourney_itemstable exist with correct columns, FK constraints (ON DELETE CASCADEfromgeschichten,ON DELETE SET NULLfromdocuments), and CHECK constraint (document_id IS NOT NULL OR note IS NOT NULL)JourneyItem.notehas field-level comment covering ALL render contexts AND naming the CWE so it is greppable:// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email renderer MUST escape this; only Svelte {note} is auto-safe.JourneyItem.positionhas a field-level comment:// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order — the editor is responsible for keeping them distinct.JourneyItem.documentis annotated@JsonIgnore;documentId(UUID) is exposed as the serialized field — confirmed no circular reference / StackOverflowError risk@Schema(requiredMode = REQUIRED)onidandposition;documentIdandnoteleft optional (nullable)Geschichte.documents(Set) is removed;Geschichte.items(List, ordered by position,cascade=ALL, orphanRemoval=true, LAZY) is added with the bag/ADR-022/open-in-view inline commentGeschichteService.getById()is annotated@Transactional(readOnly = true)AND explicitly initializesitems(getItems().size()orHibernate.initialize) inside the transaction —open-in-view:falsemeans the collection is otherwise dead at serialization timeGeschichteService.list()returns aGeschichteSummaryprojection that never carriesitems(nogetItems()reachable by Jackson on the grid path); aligns withPersonSummaryDTOprecedentGeschichteSummarycarries exactlyid, title, status, type, author, publishedAt, body— NOTitems, NOTpersons(matches the live grid card; no byline/excerpt regression, no unused association)GeschichteSummaryis a Spring Data interface-based projection returned from an explicit@QuerywithORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC(replaces theSpecification/orderByDisplayDateDesccomposition on the list path)effectivestatus (currentUserHasBlogWrite() ? status : PUBLISHED) +authorIdconstraints (GeschichteService:81/84) are applied to the projection query — DRAFT journeys do NOT leak into the public gridhasAllPersons(grid person-filter) reimplemented for the projection query — person filtering remains shipped behaviour; onlyhasDocumentis removedGeschichte.typecolumn exists, defaults toSTORYfor all existing rowsGeschichteUpdateDTO.documentIdsremoved;GeschichteService.resolveDocuments()removed;create()andupdate()no longer callresolveDocuments()GeschichteSpecifications.hasDocument()removed;documentIdparameter removed fromGeschichteService.list();// TODO(lesereisen-editor)comment left at removal site;hasStatus/hasAuthor/hasAllPersonsretained@RequestParam documentIdremoved fromGeschichteController.list()and from thegeschichteService.list(...)call siteimport ...document.Documentremoved fromGeschichteService;documentServicefield retained with// Reserved for lesereisen-editorcomment (no unused-field lint gate fails)documentIdsites fixed: (1)geschichten/+page.server.tsquery param +documentFilterreturn; (2)geschichten/new/+page.server.tsprefetch; (3)geschichten/+page.svelte:14hasFiltersderived readingdata.documentFilter(real compile error); (4)geschichten/+page.svelte:19deadsearchParams.delete('documentId')(cleanup). Grid consumesGeschichteSummary[];npm run checkpassesgeschichten_journey_letter_link(Brief öffnen/Open letter/Abrir carta) added tomessages/{de,en,es}.json[id]/+page.svelterelated-letters block switched to{#each g.items as item (item.id)}; document-backed items render the generic localized label link (descriptive text, WCAG 2.4.4 — never a bare UUID) withdocumentIdin thehref; interlude (null-documentId) items rendernotein<p class="whitespace-pre-line">;GeschichteEditor.svelteverified non-breaking undernpm run checknpm run test):geschichten/[id]/page.svelte.test.tsdocument-section tests rewritten tog.items(including an interlude/note-only assertion; document-link asserted viagetByRole('link', { name: ... }));edit/page.svelte.test.tsdocumentsfixture updated, no document round-trip assertion remainsgeschichten_documentsdata; positions at multiples of 1000 with deterministic ORDER BY +SELECT DISTINCTguard; single V72 file;pg_dumpcomment says "BEFORE applying this migration"; reverse-reconstruction SQL present in the migration commentidx_journey_items_geschichte_positionon(geschichte_id, position ASC)existsGeschichteServiceTestandGeschichteControllerTest: builder helpers using.documents(new HashSet<>())updated;dto.setDocumentIds(...)removedgeschichteService.list(...)call sites inGeschichteServiceTest(lines 134, 148, 161, 174, 186, 198) drop thedocumentIdpositional argument (arity change);list_filters_by_documentId(line 180) is DELETED outright (it tests a removed spec/param and cannot compile)create_resolves_documentIds_via_DocumentServicetest (GeschichteServiceTest:286) replaced withcreate_does_not_call_DocumentService_for_document_resolution(): assertverify(documentService, never()).getDocumentById(any())and delete the now-invalidassertThat(saved.getDocuments())...assertion (line 301)list()tests updated to assert against theGeschichteSummaryprojection (noitemsfield)backend/src/test/resources/referencinggeschichten_documentsupdated@PersistenceContext EntityManager emintoGeschichteServiceIntegrationTest(it is@Transactionalat class level, line 35 — tests pass vacuously from the first-level cache without flush/clear)@OrderBytest: insert items out of position order (e.g. 3000, 1000, 2000),em.flush()+em.clear(), thengetByIdand assert the list is [1000, 2000, 3000]. In-order insert proves nothing.delete(id),em.flush()+em.clear(), thenjourneyItemRepository.findAllByGeschichteId(id)returns empty (addJourneyItemRepositoryto@Autowiredlist)typeround-trip: STORY →getType()==STORY; newly created JOURNEY →getType()==JOURNEYdocument_id=null,notepopulated) persists and returns without NPE — covers the null arm ofgetDocumentId()document_id+note) — covers the present arm ofgetDocumentId()(both arms needed for the 88% branch gate, perbackend/CLAUDE.md)JourneyItem,em.flush()/clear(), calldocumentService.deleteDocument(docId),em.flush()/clear(), reload theJourneyItemand assertgetDocumentId() == nullAND the row still exists. Locks theON DELETE SET NULL"preserves curator prose" decision — this transition is otherwise untested.journeyItemRepository.saveAndFlush(bothNull)(goes through the Spring@Repositorytranslation layer) andassertThatThrownBy(...).isInstanceOf(DataIntegrityViolationException.class). If instead flushing via a bareem.flush(), the thrown type is Hibernateorg.hibernate.exception.ConstraintViolationException(wrapped inPersistenceException), NOTDataIntegrityViolationException— picksaveAndFlushto keep the stated type consistent. The constraint fires on flush, not onsave(). Additionally assert the exception message/constraint name containschk_journey_item_not_emptyso a stray NOT NULL elsewhere can't make it pass for the wrong reason.@SpringBootTest(webEnvironment = RANDOM_PORT)class (NOT class-level@Transactional, so the session closes before serialization like production; add class comment// NOT @Transactional — must serialize after commit to reproduce open-in-view:false lazy-init 500.) creates a JOURNEY with items, commits, then over HTTP:GET /api/geschichten/{id}asserts$.itemspresent and ordered,$.items[0].documentIdexists,$.items[0].documentdoesNotExist(),$.typeexists$.items[1].documentIdis null/absent AND$.items[1].notepresent (the frontend stub depends on the API emitting a null-documentId item)GET /api/geschichten(grid) asserts 200 AND$[0].authorexists AND$[0].bodyexists AND$[0].itemsdoesNotExist()(round 5 — a bare 200 passes while the card goes blank if the projection drops author/body)BLOG_WRITE) user, create a DRAFT JOURNEY;GET /api/geschichtenasserts the draft is absent from the grid;GET /api/geschichten/{id}of that draft asserts 404. Locks the projection-rewrite carryover of the status clamp.getById_returns_type_field_in_response()inGeschichteControllerTest: the$.items[0].documentdoesNotExist()assertion is the regression lock on@JsonIgnore(Lombok@DatageneratesgetDocument(); verify it does not leak). (Note: this@WebMvcTest-style assertion is the contract lock; theRANDOM_PORTtest above is the lazy-init lock — both are needed.)INSERT...SELECT) verified manually against a productionpg_dumprestored into a throwawaypostgres:16-alpinebefore production deploy, documented in PR." Measurable form: post-migrationCOUNT(journey_items)equals pre-migrationCOUNT(DISTINCT (geschichte_id, document_id))from the junction; positions are strictly increasing multiples of 1000 within eachgeschichte_id; zero rows with bothdocument_idandnotenull. Paste this query output into the PR (checklist item, not prose).@OrderBy/column-name mismatch between V72 and the entity fails in CI, never crash-loops production after the schema is already migratednpm run generate:apirun after entity changes — TypeScript types regenerated (Geschichte.documents→Geschichte.items; newGeschichteSummarytype for the grid)GET /api/geschichten/{id}now returnsitems(withdocumentIdUUID per item, not the full document object) instead ofdocuments;GET /api/geschichten(list) returnsGeschichteSummaryobjects withoutitems; full reader/editor frontend redesign is the follow-on Lesereisen frontend issuedocs/architecture/db/db-orm.puml— removegeschichten_documents, addjourney_itemswith columns and FK relationshipsdocs/architecture/db/db-relationships.puml— update relationship linesdocs/architecture/c4/l3-backend-*.pumlfor the geschichte domain — add the newjourneyitem/sub-package (per doc-currency: new backend package/module)CLAUDE.mdpackage structure table — addjourneyitem/sub-package undergeschichte/docs/GLOSSARY.md— addGeschichteType,JourneyItem,Lesereisewith display names in de/en/es (Lesereise/Reading Journey/Lectura guiada)GeschichteSummarylist-projection split, and the 1000-gap position strategyReview Insights
Decided
GeschichteSummaryfield set (BLOCKER, round 5 — hit by 4 personas)id, title, status, type, author, publishedAt, body; droppersonsid, title, status, type, publishedAt, persons + any other) silently dropped theauthorbyline andbodyexcerpt (both rendered by the live grid card+page.svelte:131–146) — a visible regression hidden inside the "fix the 500" change — AND carriedpersons, which the card never reads (an unused association reintroducing query cost). Enumerating the exact set removes the untestable "(and any other fields)" hand-wave. Addpersonslater, in the follow-on grid-redesign issue, only with the card change that consumes it.GeschichteSummaryquery mechanism (round 5)@Query ... ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESCGeschichteSpecifications.orderByDisplayDateDesc()injectsquery.orderBy(...)on the entity root with a COUNT special-case; it does not compose cleanly with a constructor-expression/projection query. An interface projection with an explicit ORDER BY sidesteps the spec entirely on the list path. The DRAFT status-clamp andhasAllPersonsfilter must be carried into this query.effectivestatus +authorIdconstraints into the projection query; add a list-path authz testlist()currently clamps non-BLOG_WRITEusers toPUBLISHED(GeschichteService:81/84). A freshly-written projection query that forgets this leaks DRAFT journeys into the public grid — a real authz regression. The new READ_ALL-only test (draft absent from grid + 404 on getById) is the regression lock.hasAllPersonsretained (round 5)hasDocumenthasDocumentbut was silent onhasAllPersons; it must survive.geschichten_journey_letter_link) +documentIdin href (Option A)itemspayload exposes onlydocumentId(thedocumentobject is@JsonIgnore'd) — no title/date is available. A UUID or empty<a>fails WCAG 2.4.4 (Link Purpose) and is unusable for the 60+ audience. Fetching document titles (Option B) pulls reader-redesign work into this data-model issue (scope creep) — that belongs to the follow-on. Add the i18n key this issue.journeyItemRepository.saveAndFlush(bothNull)→DataIntegrityViolationExceptionem.flush()throws HibernateConstraintViolationException(wrapped inPersistenceException) — Spring'sDataIntegrityViolationExceptiontranslation only happens at the@Repositoryproxy boundary, not on a bareEntityManager.saveAndFlushkeeps the AC's stated type consistent and still fires on flush. Keep thechk_journey_item_not_emptymessage assertion.$[0].author+$[0].bodypresent AND$[0].itemsabsent200passes even if the projection drops author/body (card goes blank) — the shape assertion is the only test that catches the projection field-set regression.documentId+ present-noteassertion to the RANDOM_PORT getById testpsql/pg_dumpinsh -c '...'so$POSTGRES_USER/$POSTGRES_DBexpand inside the containerpg_dumpinto a throwawaypostgres:16-alpine, run Flyway, paste query outputINSERT...SELECTdata path is genuinely untested in CI. Only real production junction data exercises thedocument_date ASC NULLS LAST, id ASCtiebreaker and theSELECT DISTINCTdedup; a synthetic fixture lacks the duplicate/null-date edge cases.notefield comment names CWE-79 (round 5)CWE-79 tripwire:{note}auto-escapes; feed/PDF/email do not).itemsfetch strategy + serialization shape (BLOCKER, round 4)getItems().size()init ingetById();list()returns aGeschichteSummaryprojectionapplication.yaml:15open-in-view: false. Two production 500s result: (1)@Transactionalalone does NOT hydrate a LAZY collection — Jackson 500s after the session closes ongetById; (2)list()is non-transactional and serializesgetItems()→ 500 the moment any journey exists. Fix: keep LAZY, force-inititemsinsidegetById()'s transaction, and givelist()a dedicatedGeschichteSummarythat never carriesitems. Rejected:@JsonIgnore items+ separate/itemsendpoint (extra round-trip); force-init in both paths (reintroduces Cartesian cost).@SpringBootTest(webEnvironment = RANDOM_PORT)class, no class-level@Transactional@Transactionaltest runs inside its own transaction so LAZYitemsalways init — it passes vacuously while production 500s underopen-in-view:false. Only a real-HTTP test that serializes after the transaction commits reproduces the bug. The single most important new test.JourneyItem.documentIdnull, row survivesON DELETE SET NULL"preserves curator prose" contract was decided round 2 but untested.DocumentService.deleteDocumentdoes a plaindeleteById; the test proves the cascade rule fires and the item row is retained.positionwithin a journey (round 4)UNIQUE (geschichte_id, position)constraint forces the editor's drag-reorder to compute distinct positions every move and complicates naive swaps. The 1000-gap seed keeps positions distinct in practice. State in AC + field comment; revisit if the editor needs the hard guarantee.geschichten_documentswas an unordered Set — no curator order existed. Ordering bydocument_dateis a plausible default a Lesereise lets curators re-sequence. Prevents a future reviewer treating chronological order as a requirement.notelength bound (round 4)length(note) <= Nmirroringchk_text_length(proposed 2000 chars). The column is created here, so the threshold is registered now to avoid drift.getById()getById()returns NOT_FOUND (not FORBIDDEN) for DRAFT withoutBLOG_WRITE, to avoid leaking existence. A DRAFT JOURNEY inherits this — the follow-on must not add a/journeys/{id}endpoint that skips the check.journey_items.document_idON DELETESET NULLCASCADEwould destroy editorial work;RESTRICTblocks archival workflows.notefield format{note}, not{@html}), no sanitization pipeline. Field comment (now CWE-79-tagged) warns feed/email/PDF must escape. No unescaped sink exists today.documents.document_date ASC NULLS LAST, documents.id ASCSELECT DISTINCT (geschichte_id, document_id)JourneyItempackage locationgeschichte/journeyitem/sub-packagedocument/transcription/anddocument/annotation/.documents, additems(getById) +GeschichteSummary(list); full frontend redesign deferred — but build-breakingdocumentIdreferences AND now-failing frontend tests fixed nownpm run testcannot ship "known broken."GeschichteSpecifications.hasDocument()geschichten_documentsis dropped, the spec joins a non-existent table → runtime SQL 500. Rewrite viajourney_itemsdeferred to the editor issue.hasStatus/hasAuthor/hasAllPersonsare retained.GeschichteControllerdocumentIdparamlist()param is removed, so the controller arg would not compile.JourneyItem.documentserialization@JsonIgnore+ exposedocumentIdUUID (Option B)$.items[0].document doesNotExist()assertion (Lombok@DatageneratesgetDocument()). Not an IDOR risk — READ_ALL is global, no per-document ACL;documentIdleaks no more thanGET /api/documents/{id}already does.@SchemaonJourneyItemid,positionREQUIRED;documentId,noteoptionalflush()+clear()inside the existing@Transactionalclasscascade=ALL+orphanRemovalis the mapping guarantee; DB FK cascade is belt-and-suspenders. Avoids cross-test contamination.tag-name-resolution) and 034 (ollama-deployment) are taken.docs/specs/lesereisen-reader-spec.html+lesereisen-editor-spec.htmldocs/superpowers/specs/...mddoes not exist (verified).[id]test handlingg.itemsnowErrorCode/PermissionresolveDocumentsthrewDOCUMENT_NOT_FOUNDvia a path still live elsewhere — enum value not orphaned. Nodocs/ARCHITECTURE.mdpermission-section update.StatsServiceimpactcountPublished()→count(spec), neveritems/projection. Safe.Risks to watch
GeschichteSummaryfield-set regression is the highest-leverage round-5 finding — four personas independently hit it. Shipping the round-4 draft list silently strips the author byline + body excerpt off every story card (a visible regression hidden in the 500-fix) and carries an unusedpersonsassociation. The grid HTTP test's$[0].author/$[0].bodypresent +$[0].itemsabsent assertions are the only gate that catches it.effective/authorId(GeschichteService:81/84) leaks DRAFT journeys into the public grid. Authz regression, not hypothetical. The READ_ALL-only list-path test is the lock.open-in-view: falseturns the round-3 LAZY design into two guaranteed production 500s unlessgetById()explicitly initsitemsANDlist()uses theGeschichteSummaryprojection. Service-layer/@Transactionaltests CANNOT see this; the dedicatedRANDOM_PORTHTTP test is the only gate.itemsis the firstListbag onGeschichte— adding a second EAGER/fetch-joinedListthrowsMultipleBagFetchExceptionat boot. Field comment documents this. If a future maintainer reverts to EAGER, add a test assertinglist()returns no duplicate Geschichte rows.flush()+clear(): the class is@Transactional.@OrderBymust insert out-of-order; cascade delete, document-delete SET NULL, and the negative CHECK must flush to hit the DB. The CHECK fires on flush, notsave().saveAndFlush→DataIntegrityViolationException(Spring translation); bareem.flush()→ HibernateConstraintViolationException. PicksaveAndFlushto match the AC's stated type.@JsonIgnoreregression risk: Lombok@DatageneratesgetDocument(); Jackson serializes by getter, but honours field-level@JsonIgnore. Lock with$.items[0].document doesNotExist().params.query.documentIdcompile error in two+page.server.tsfiles;+page.svelte:14hasFiltersderived readingdata.documentFilter(real compile error);+page.svelte:19deadsearchParams.delete(cleanup); two[id]/page.svelte.test.tstests fail at runtime (rewrite tog.items).list_filters_by_documentIdinGeschichteServiceTestmust be DELETED outright (removed param) and alllist(...)call sites drop the positional arg./geschichten/[id]/+page.svelterenders related-letters for all Geschichten — existing published stories lose rich title/date links (degrade to generic "Open letter" links) until the follow-on. The "start a story pre-linked to this document" entry point also dies until the editor restores it. Accepted, signed-off, follow-on linked.itemspayload has no title/date (onlydocumentId); a bare-UUID or empty<a>fails WCAG 2.4.4. Use the localized label; assert viagetByRole('link', { name }).item.documentIdfor null AND applywhitespace-pre-lineto the note<p>(one class) so hand-authored multi-paragraph notes (creatable via the editor while the stub is live) don't render run-on. Follow-on reader must usewhitespace-pre-line+ 16px-minimum body for the 60+ audience.$POSTGRES_USER/$POSTGRES_DBonly exist inside the container; wrap insh -c '...'or the one-liners fail at the host shell.depends_on: service_healthygate — a V72/entity column mismatch makes the new image fail Hibernateddl-auto: validateon first boot (after the schema migrated); without a gate the proxy may route to a restart-looping container → 502s instead of a clean failed deploy. PR must paste the actualhealthcheck:block + proxydepends_onas evidence, or flag its absence as a pre-existing gap (do not block this issue).pg_dumpinto a throwawaypostgres:16-alpine, run Flyway, paste row-count equality + position spacing + no-double-null query output into the PR. A synthetic fixture lacks the real duplicate/null-date edge cases.DROP TABLE geschichten_documentsis the only consequential DDL —INSERT...SELECTis a no-op if production has no journeys-with-documents. Take thepg_dumpanyway. Reverse-reconstruction SQL is in the migration comment. Run the pre-deployCOUNTto know whether the data path is live.noteis a NEW unescaped serialization sink (CWE-79 latent) —Geschichte.bodyis sanitized on write;JourneyItem.noteis serialized raw. Safe for{note}(auto-escaped); feed/PDF/email are future surfaces that MUST escape. The CWE-79-tagged field comment is the only tripwire.JourneyItem.documentIdwrites (editor issue) must resolve throughDocumentService.getDocumentById, never blind-persist a client UUID. No per-document ACL today (READ_ALL global), so pattern-hygiene. Migration'sINSERT...SELECTcopiesdocument_idfrom an existing junction row — no orphan-FK/TOCTOU risk (a junction row can't reference a non-existent document).postgres:16-alpine) — H2 lacks both; Mockito does not exercise DB constraints.personscollection stays onGeschichte— theSet<Person>ManyToMany is intentionally NOT removed; a JOURNEY carries a denormalizedpersonsset alongside itsitems. With theGeschichteSummaryprojection the grid no longer Cartesian-joinspersons×items. Document the dual-collection shape in the sub-package README if it confuses.Decided (round 6)
GeschichteSummary.authorgrain (BLOCKER, round 6 — raised by Markus, seconded Felix + Elicit)getAuthor()returns an interface exposing exactlyfirstName,lastName,email+page.svelte:41authorName(g)) readsg.author.firstName / .lastName / .email— a nestedAppUser-shaped object. A flatString getAuthor()orUUID getAuthorId()satisfies the AC's literal "carries author" text AND passesnpm run check(the card typesauthor?loosely), yet renders an empty byline at runtime and the round-5$[0].author existsassertion passes anyway. Return a nested closed projection — NOT a flat string and NOT the fullAppUserentity (which would re-expose the password hash and re-introduce a serialization surface). Minimal shape that keeps the byline and avoids over-exposure; costs one join. The HTTP grid test MUST assert$[0].author.email(or.firstName) present — not bare$[0].author— so the nested-shape regression fails at the test layer. Copy the exact inline type the card already declares at+page.svelte:41({ firstName?; lastName?; email }).aria-labelwith the item's 1-based position ("Open letter 1", "Open letter 2"…) — Option Bgeschichten_journey_letter_link; thearia-labelcarries the position suffix.relates-to/blocks, and carry the restore-title+date+heading AC before this issue merges.Geschichte.itemsinline commentdocs/adr/contains TWO 022 files:022-csrf-session-revocation-rate-limiting.mdAND022-eager-to-lazy-fetch-strategy.md. The inline comment must read// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.mdso a future maintainer is not sent to the CSRF doc. ADR-035 (if written) should note: extends022-eager-to-lazy, supersedes nothing; records thatitemsis the firstListbag onGeschichte(theMultipleBagFetchExceptiontripwire) as a consequence; and notes thejourneys_items.document_id ON DELETE SET NULLfollows the existinggeschichten.author_id ON DELETE SET NULL(V58→V60) house pattern, so it reads as continuation not invention.hasAllPersonsAND-semantics regression lock (round 6 — Sara)@DataJpaTestperson-filter AND-semantics test (story{A,B} vs story{A}, filter?personId=A&personId=B→ only {A,B})hasAllPersonsis "one EXISTS subquery per id" Criteria-API code that does NOT survive into a JPQL projection query for free. A naiveJOIN g.persons p WHERE p.id IN :idsgives OR semantics (matches any), silently widening the filter — and no existing test asserts AND, so an AND→OR regression passes CI today. The JPQL must use a counting subquery (e.g.(SELECT COUNT(DISTINCT p.id) FROM g.persons p WHERE p.id IN :personIds) = :personCount). The AND-semantics test against real Postgres is the single missing regression lock.list()test split mechanism (round 6 — Sara)effectivestatus +authorId, and (b) a@DataJpaTest(Testcontainers Postgres) asserting clamp + COALESCE ordering + person-AND against real rows@Queryis only testable via real Postgres (@DataJpaTest/Testcontainers), not Mockito. The six survivinglist(...)Mockito tests (lines 134/148/161/174/186/198) can only assert the repo method is called with the clamped status; the security-critical clamp/ordering/AND behavior moves to@DataJpaTestwith real data. Also grep those six tests for.getPersons(/.getDocuments(on thelist()result —GeschichteSummaryhas nopersons, so any such assertion breaks beyond the arity change and must convert or move to@DataJpaTest.WHEREclause (getById's NOT_FOUND guard atGeschichteService:67is untouched). Asserting list size alone is weak — a draft could be absent for unrelated reasons. Create one PUBLISHED + one DRAFT, run the grid as a non-BLOG_WRITEuser, assert published id present AND draft id absent.documentDatespan +formatDateimport (round 6 — Felix)[id]/+page.sveltedelete theformatDate(d.documentDate)span (lines 113–117) and drop theformatDateimport if now unused{d.title}ANDformatDate(d.documentDate). Post-regenitem.documentDatedoes not exist on the projection — it is a TS compile error, not just dead code. Rewriting only the{#each}is insufficient; the date span and (if orphaned) the import must go. Keep thegeschichten_documents_section<h2>heading — it is still the section heading for the stub period; do NOT delete thegeschichten_documents_sectioni18n key (one usage,[id]/+page.svelte:103). The follow-on reader replaces the heading.COUNT(DISTINCT (geschichte_id, document_id))on the source; assertion is "COUNT(journey_items)≤ source, any shortfall explained by theSELECT DISTINCTdedup" — NOT strict equalitySELECT DISTINCT (geschichte_id, document_id). If production has duplicate junction rows,COUNT(journey_items) < COUNT(*)and a strict-equality smoke check FAILS on a correct migration. Step-1 pre-check must beCOUNT(DISTINCT ...)(notCOUNT(*)); the runbook must say "≤, shortfall = dedup worked," not "must equal." Reconciles the runbook with the measurable AC.pg_dumpdumps ONLY--table=geschichten_documents— drop--table=journey_itemsjourney_itemsdoes not exist pre-migration (V72 creates it), sopg_dump --table=journey_itemswarns/errors on a missing table. The post-migration table needs no pre-backup; it is reconstructable from the dump + reverse SQL. (Update the migration-commentpg_dumpone-liner accordingly.)pg_dumprestored into the throwawaypostgres:16-alpinemust be schema-current (≥V71)ALTER TABLE users RENAME TO app_usersagainst an already-renamed schema and fail. Use a full current dump so Flyway resumes at V72.geschichten_documents.document_idFK isON DELETE CASCADEper the original V58 (NOT the newSET NULLofjourney_items); and post-V60 the domain FKs targetapp_users, notusersSET NULLrule, or hand-typing V58's verbatimREFERENCES usersDDL, would silently change delete behavior or fail on the renamed table. The captured dump definition already saysapp_users; the comment must warn against hand-typing the old V58 text and against copying the new SET-NULL semantics into the junction.frontend/src/lib/geschichte/README.mddoc-currency (round 6 — Markus)documents(the entity-shape changedocuments→items), it is a doc blockerAdditional AC items (round 6)
GeschichteSummary.getAuthor()is a nested closed projection exposing exactlyfirstName,lastName,email(NOT a flat string, NOT the fullAppUser); the grid HTTP test asserts$[0].author.email(or.firstName) present — not bare$[0].authoraria-labelwith the item's 1-based position (distinct accessible names; WCAG 2.4.4), preserves theblock px-4 py-3(≥44px) touch target; interlude note paragraph uses<p class="whitespace-pre-line text-ink-2">(explicit text token for dark-mode contrast)relates-to/blocks) with a "restore per-item title + date + journey heading" AC BEFORE this merges (Definition of Ready)Geschichte.itemsinline comment citesdocs/adr/022-eager-to-lazy-fetch-strategy.mdby FILENAME (not "ADR-022" — collides with the CSRF 022)@DataJpaTest: story{A,B} + story{A};?personId=A&personId=Breturns exactly {A,B} (lockshasAllPersonsAND semantics through the projection rewrite; JPQL uses a counting subquery, not a naiveINjoin)list()test split: (a) Mockito — repo projection method invoked with clampedeffectivestatus +authorId; (b)@DataJpaTest(Testcontainers Postgres) — clamp + COALESCE ordering + person-AND against real rows; six surviving Mockitolist(...)tests grepped for.getPersons(/.getDocuments(on the result and converted/moved[id]/+page.svelte:formatDate(d.documentDate)span deleted;formatDateimport dropped if orphaned;geschichten_documents_sectionheading + i18n key retained for the stub periodCOUNT(DISTINCT (geschichte_id, document_id)); assertion is "≤, shortfall = dedup"; pre-migrationpg_dumpdumps onlygeschichten_documents; throwaway-container verification uses a schema-current (≥V71) production dumpON DELETE CASCADE(V58), domain FKs targetapp_users(post-V60) — do not hand-type V58'sREFERENCES usersnor copyjourney_items' SET-NULL into the junctionfrontend/src/lib/geschichte/README.mdgrepped; updated if it referencesdocuments(doc blocker)Risks to watch (round 6 additions)
GeschichteSummary.authorflat-vs-nested is a second hidden byline regression — even with the field named, a flatString/UUID authorpassesnpm run checkand the round-5$[0].author existsassertion while the byline renders empty. Only a nested closed projection + an$[0].author.email(not bare$[0].author) assertion catches it.hasAllPersonsAND→OR silent widening — rewriting the EXISTS-per-id Criteria filter as a naiveJOIN ... WHERE p.id IN :idsin JPQL gives OR semantics; no existing test asserts AND, so the regression passes CI. Lock with the {A,B}-vs-{A}@DataJpaTest.@Queryis@DataJpaTest-only — the clamp/ordering/AND logic cannot be Mockito-tested; the security-critical clamp must be verified against real Postgres rows, not just "repo method called."COUNT(*)equality FAILS on a correct dedup migration if production has duplicate junction rows; useCOUNT(DISTINCT ...)and "≤" everywhere.pg_dump --table=journey_itemserrors — the table doesn't exist yet; dump onlygeschichten_documentspre-migration.marcel referenced this issue2026-06-07 19:37:55 +02:00
marcel referenced this issue2026-06-07 19:38:53 +02:00
marcel referenced this issue2026-06-07 19:39:52 +02:00
marcel referenced this issue2026-06-07 19:40:39 +02:00
marcel referenced this issue2026-06-07 19:45:23 +02:00
marcel referenced this issue2026-06-07 19:47:26 +02:00
marcel referenced this issue2026-06-07 19:47:43 +02:00
marcel referenced this issue2026-06-07 19:52:29 +02:00
marcel referenced this issue2026-06-08 10:44:14 +02:00
marcel referenced this issue2026-06-08 10:44:42 +02:00
marcel referenced this issue2026-06-08 10:45:15 +02:00
marcel referenced this issue2026-06-08 10:45:25 +02:00
marcel referenced this issue2026-06-08 10:51:15 +02:00
marcel referenced this issue2026-06-08 10:52:44 +02:00
marcel referenced this issue2026-06-08 10:53:07 +02:00
marcel referenced this issue2026-06-08 10:53:30 +02:00
marcel referenced this issue2026-06-08 10:59:07 +02:00
marcel referenced this issue2026-06-08 11:00:58 +02:00
marcel referenced this issue2026-06-08 11:01:25 +02:00
marcel referenced this issue2026-06-08 11:08:06 +02:00
marcel referenced this issue2026-06-08 11:09:39 +02:00
marcel referenced this issue2026-06-08 11:09:58 +02:00
marcel referenced this issue2026-06-08 11:10:15 +02:00
Implemented ✅
Branch:
feat/issue-750-lesereisen-data-modelCommits
b3ce9b93—feat(geschichte): add GeschichteType, JourneyItem entity, GeschichteSummary, and V72 migration439385dd—refactor(geschichte): update service, controller, repository for JourneyItem model93ef2669—test(geschichte): add JourneyItemIntegrationTest, GeschichteListProjectionTest, GeschichteHttpTest; update unit testse6c890c6—feat(frontend): update generated API types and Geschichte routes for JourneyItem modelccdf358b—test(frontend): fix Geschichte component specs for GeschichteType and JourneyItem model13fa4123—docs: update ARCHITECTURE, GLOSSARY, db diagram for JourneyItem and LesereiseWhat was done
Backend:
GeschichteTypeenum{STORY, JOURNEY}; defaultSTORYon all existing GeschichtenJourneyItementity (journey_itemstable) with@OrderBy("position ASC"),CascadeType.ALL,orphanRemoval=true; CHECK constraint;@JsonIgnoreondocument;getDocumentId()getterGeschichteSummaryinterface projection for list() — avoids LazyInitializationException in open-in-view=falsetypecolumn, createsjourney_items, migratesgeschichten_documentsordered bymeta_date, drops junction tableGeschichteService:list()returnsList<GeschichteSummary>via JPQL;getById()is@Transactional(readOnly=true)withHibernate.initialize(g.getItems())GeschichteController: removeddocumentIdparam, returnsGeschichteSummary[]Frontend:
api.ts:GeschichteSummary,JourneyItem,GeschichteTypeschemas; list response changed;Geschichtegainstype+items;documentIdsremoved from DTOGeschichteEditor: document picker removed (journey editing is follow-on)geschichten/[id]/+page.svelte: document list replaced with JourneyItem stub block (document-backed items link to/documents/{documentId})geschichten/+page.server.ts:documentIdfilter removedFollow-on
Filed as separate issue linked to this one (reader UI next).