Publishes DocumentDeletingEvent from DocumentService.deleteDocument before
deleteById; JourneyItemDocumentDeleteListener handles it synchronously so
note-less items are gone before ON DELETE SET NULL fires on note-carrying rows.
Plain @EventListener chosen over AFTER_COMMIT (fires too late) and @Async
(breaks rollback atomicity) — see ADR-038. Adds DOCUMENT_DELETED to AuditKind.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Delete the JOURNEY-only type guard in JourneyItemService.append() so the
existing item endpoints serve both Geschichte types. GeschichteType has
exactly two constants, so an allowlist replacement would be unreachable
dead code. Fix the not-found messages that claimed "Journey", and remove
the now-orphaned GESCHICHTE_TYPE_MISMATCH error code end to end
(ErrorCode, errors.ts union + mapping, i18n keys in de/en/es).
Tests: three STORY append unit tests written red against the guard, plus
end-to-end STORY coverage (append+retrieve, V72-style position-gap rows
incl. removal, dangling document-deleted item). The two STORY-rejection
tests die with the guard — no third enum value exists to feed it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Title: requireTitle() turns the VARCHAR(255) DB bound into a friendly 400
(GESCHICHTE_TITLE_TOO_LONG). JOURNEY intro: MAX_INTRO_LENGTH = 4000 in
bodyForType() plus a V75 CHECK as atomic backstop (defensive clamp first,
STORY bodies exempt) — GESCHICHTE_INTRO_TOO_LONG. Both codes wired through
ErrorCode.java, errors.ts, getErrorMessage and de/en/es. DB-layer boundary
pins added: exactly-2000 note insert (V74 CHECK) and 4000/4001 intro
inserts against real Postgres. Docs: error-code lists, db puml diagrams.
The matching maxlength attributes land with the component commits.
Review round 3: Markus (b), Nora (1), Sara (DB 2000 boundary).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dedup DELETE + note clamp before the DDL so the unique index and CHECK
cannot fail mid-migration (failed Flyway row -> backend boot loop) on a
DB that served writes under the old code (no dedup guard, 5000-char
notes). No-ops on a clean database.
Note: this changes V74's checksum — dev databases that already ran V74
on this branch need 'flyway repair' (or a fresh DB). CI and prod run
from V73 or clean and are unaffected.
Review round 3: Tobias (1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Any other DataIntegrityViolationException at flush (e.g. an FK violation
on a concurrently deleted document) is rethrown instead of being
mislabeled as JOURNEY_DOCUMENT_ALREADY_ADDED. Match on the
uq_journey_items_geschichte_document constraint name.
Review round 3: Markus (a), Felix suggestion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI caught three spots the targeted local runs missed: the relevance-path
unit tests still stubbed findAllById (the path now calls findByIdIn — the
batchMetadata stubs legitimately keep findAllById), the second
GeschichtenCard test file still expected the removed email fallback, and
the AND/OR-toggle describe lacked the wait-for-slide-transition guard its
sibling describe documents — the flake that failed run 2208.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captures the read-model invariant that was only living in a code comment:
views are assembled in-transaction, entities never cross the controller
boundary in the geschichte domain. CLAUDE.md §DTOs now names the exception
so the convention text stops contradicting the code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OWASP sanitizer entity-encodes ('&' → '&') while JourneyReader
renders the intro via Svelte text interpolation — a curator typing
'Müller & Söhne' saw 'Müller & Söhne', re-encoded cumulatively on every
editor round-trip. JOURNEY bodies now bypass the sanitizer (safe: the reader
never uses {@html}); STORY bodies keep the full allow-list sanitization.
This makes the code match the PR's documented design note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /api/geschichten shipped every author's AppUser email to all readers via
GeschichteSummary.AuthorSummary — contradicting the documented rule that
author projections never expose email or group memberships. The frontend
only used it as a display-name fallback; it now falls back to [Unbekannt],
matching the server-side rule in GeschichteService.toView.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The append dedup guard was check-then-insert: two concurrent appends of the
same document both pass exists() and both insert. The partial unique index
on (geschichte_id, document_id) WHERE document_id IS NOT NULL closes the
race; append saveAndFlush-es and maps the DataIntegrityViolationException to
the same 409 JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check. The
CHECK on note length pins the 2000-char contract in the schema, mirroring
chk_text_length on transcription_blocks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GESCHICHTE_TYPE_IMMUTABLE and JOURNEY_NOTE_TOO_LONG were declared in
errors.ts, translated, and documented — but never existed in the backend.
update() now rejects a type change with 409 (omitted/same type still pass);
note length is enforced at 2000 with its own code, matching the frontend
maxlength and the i18n message (resolves the #793 discrepancy in favour of
the spec). JOURNEY_ITEM_NOT_IN_JOURNEY is deleted everywhere instead — the
deliberate 404 posture for cross-journey item ids must not leak existence
via a distinct code.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
POST /api/geschichten/{id}/items with a documentId failed 500: Spring Data
resolved the derived existsByGeschichteIdAndDocumentId path as a direct
documentId attribute (shadowed by the transient getDocumentId() getter)
instead of document.id, producing JPQL Hibernate cannot map. Existing tests
only appended note items, so the document branch was never exercised.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The reader dashboard renders 'bearbeitet vor X' from updatedAt, but the
summary projection dropped the field when the list endpoint moved off the
entity — drafts have no publishedAt to fall back on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PATCH /api/geschichten/{id} (save draft, publish) returned the raw entity;
with open-in-view false, Jackson serialized the lazy items collection after
the transaction closed and every save failed with LazyInitializationException.
Write methods now assemble GeschichteView in-transaction, completing the
read-model boundary already used by GET — entities no longer cross the
controller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pure-text RELEVANCE fast path loaded documents via plain findAllById,
which carries no entity graph. With Document.tags LAZY (ADR-022) and no
surrounding transaction, resolveDocumentTagColors hit the dead proxy and
every q-only search (document picker typeaheads) failed with 500
LazyInitializationException. Dedicated findByIdIn declares the same fetch
shape as the other search loaders.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GeschichteUpdateDTO lacked a `type` field, so the `type: 'JOURNEY'` sent by
JourneyCreate was discarded by Jackson and every new Geschichte was saved as
STORY. The edit page branched on type, so journeys always showed the STORY
editor with no document-adding capability.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies the EXISTS subquery in findSummaries actually filters by document
membership — a journey containing the document appears, one without does not.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The PR removed the documentId filter from list() along with the old
Geschichte.documents ManyToMany, but the document-detail page and its
frontend server still query GET /api/geschichten?documentId=<id> to show
related stories. Without the filter the endpoint silently returned every
published story. Restores the filter through a JPQL EXISTS check on
journey_items so only journeys that include the given document are returned.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds JOURNEY_DOCUMENT_ALREADY_ADDED to ErrorCode, an
existsByGeschichteIdAndDocumentId() repo method, and a 409 guard in
JourneyItemService.append() — the error code was registered on the
frontend but never thrown on the backend, allowing concurrent tabs to
add the same document twice.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The method intentionally skips permission checks and tag-colour resolution.
Renaming it to findSummaryByIdInternal makes the internal-only contract
visible at every call site, closing the latent CWE-284 risk flagged in
the PR review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Re-inject JourneyItemService into GeschichteService (no cycle:
JourneyItemService → GeschichteQueryService, not GeschichteService).
Add getView(UUID) that loads the Geschichte and its items in a single
@Transactional(readOnly=true) session. Controller now delegates to
getView() instead of making two separate service calls. Tests updated
to stub getView() and cover the new method.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Create PersonNameFormatter with a single static join(firstName, lastName) method.
Replace the inline string concatenation in GeschichteService.toView() and the
private join() method in JourneyItemService with calls to PersonNameFormatter.join().
The new helper handles null-safety and trimming consistently in one place.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add @Operation annotation to reorderItems() clarifying that itemIds must
contain ALL item IDs for the journey in the desired order — a partial list
returns 400 Bad Request. This surfaces the contract in the generated
OpenAPI spec and Swagger UI.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clarify in the Javadoc that getSummaryById intentionally skips scope checks
and tag-colour resolution. This is safe under the current single-tenant model
and is explicitly used by JourneyItemService.append() to validate that a linked
document exists before persisting a JourneyItem.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
doesNotExist() asserts the key is absent from the JSON object, but Jackson
serializes a null Optional<String> as {"note": null} — the key is present with
a null value. nullValue() correctly matches that case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add two service-level integration tests to JourneyItemIntegrationTest:
- append_persists_item_at_position_10: verifies that the first append to an
empty journey creates an item at position 10 in the DB.
- reorder_swaps_positions_atomically: appends two items then reorders them,
asserting the DB reflects the new position assignment.
Both tests use the SecurityContextHolder authentication pattern from
GeschichteServiceIntegrationTest and mock S3Client to avoid MinIO connections.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add findByGeschichteIdWithDocument() to JourneyItemRepository with a
LEFT JOIN FETCH on document. getItems() now uses this query so that all
documents for a journey's items are loaded in a single SQL round-trip.
toView() now reads item.getDocument() directly from the already-fetched
association instead of issuing a separate documentService.getSummaryById()
call per item.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GeschichteService.getById() now returns the Geschichte entity (with the
DRAFT visibility guard intact). The controller calls journeyItemService.getItems()
and geschichteService.toView() to assemble the GeschichteView, removing the
need for GeschichteService to hold a reference to JourneyItemService.
Tests updated accordingly: GeschichteServiceTest tests toView() directly;
GeschichteControllerTest stubs both service calls; integration test uses the
two-step pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JacksonConfig was deleted (empty placeholder) — remove the now-stale
import and @Import reference from the controller slice test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the per-item save() loop in reorder() with a single
saveAll() call, reducing database round-trips for large journeys.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
JourneyItemService no longer injects GeschichteRepository directly.
GeschichteQueryService gains findById() so JourneyItemService.append()
can load the Geschichte entity via the service layer, satisfying the
cross-domain layering rule.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- JourneyItemService.append(): replace VALIDATION_ERROR with GESCHICHTE_TYPE_MISMATCH (409 conflict)
for non-JOURNEY type guard and JOURNEY_AT_CAPACITY (409 conflict) for 100-item cap
- JourneyItemServiceTest: update assertions to expect the new specific error codes
- CLAUDE.md: expand geschichte/ package table entry with GeschichteQueryService and journeyitem/ sub-domain
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>