Compare commits

...

80 Commits

Author SHA1 Message Date
Marcel
bee055e615 chore(merge): resolve api.ts conflict with feat/issue-750-lesereisen-data-model
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Drop stale JourneyItem/JourneyItemCreateDTO schemas — removed in base
branch when api.ts was regenerated; neither type is referenced in
frontend code (JourneyItemView is the read model used instead).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:07:29 +02:00
Marcel
9be24f2613 fix(tests): resolve 43 regressions caused by layout.css import in test-setup
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m25s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Importing layout.css in test-setup.ts activated Tailwind's responsive
breakpoint classes (hidden lg:flex, hidden md:block, etc.), making
42 elements invisible at the default narrow Playwright test viewport.

Revert the CSS import. Instead, add inline style attributes to the three
components whose tests measure computed properties (min-height, font-size)
— these values match what the Tailwind classes produce, so the real app
appearance is unchanged.

Also fix goto mock leakage in the geschichten/[id] delete-failure test:
the delete-success test's goto('/geschichten') call was not cleared before
the failure test ran. Add beforeEach(vi.clearAllMocks) to reset mock state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:53:20 +02:00
Marcel
d5441d3e55 fix(tests): resolve 10 failing browser-mode tests
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 6m5s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m55s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
- Import layout.css in test-setup so Tailwind utilities (text-xs,
  min-h-[44px]) apply in vitest-browser — fixes computed-style assertions
  for badge font-size and touch-target height
- radioGroupNav: write aria-checked directly on radio buttons on arrow-key
  navigation, not only via the optional onChangeFn callback
- DashboardNeedsMetadata spec: tighten footer-link matcher from /50/ to
  /Alle 50/ — avoids strict-mode collision with row link whose relative
  time text also contains "50" (uploadedAt is exactly 50 days ago today)
- geschichten/[id] page spec: add missing await on userEvent.click before
  confirmService.settle() in both delete tests
- TypeSelector spec: replace storyCard.focus() (not on vitest-browser
  Locator) with userEvent.click(); force-dispatch aria-disabled Weiter
  click via element.click() to bypass Playwright actionability check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:36:56 +02:00
Marcel
c131507e30 docs(c4): update l3-frontend-3c-people-stories for STORY/JOURNEY dispatch
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m4s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m12s
geschichten components now describe the type-based reader split
(StoryReader / JourneyReader / JourneyItemCard / JourneyInterlude),
the TypeSelector creation flow, and the full set of API endpoints
(including DELETE /api/geschichten/{id} and GET /api/persons/{id}
for person pre-population).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:14:30 +02:00
Marcel
c50f04bafa refactor(geschichte): use formatPublishedAt() in GeschichteListRow — remove DRY violation
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m2s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m59s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
The inline publishedAt $derived.by() duplicated the exact logic that
formatPublishedAt() in utils.ts encapsulates. Replace it with the
shared helper and drop the now-unused formatDate import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:09:53 +02:00
Marcel
f004b1f2a6 fix(a11y): add role="note" to JourneyInterlude so aria-label is announced
Without a landmark or widget role, aria-label on a generic <div> is
silently ignored by most screen readers (ARIA spec). Adding role="note"
gives the element an ARIA role that accepts an accessible name, making
the interlude label actually announced.

Also adds a test asserting role="note" and the matching aria-label are
both present on the same element.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:09:30 +02:00
Marcel
75de56928e test(storyreader): verify person chip link meets 44px touch-target height
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m57s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Mirrors the getBoundingClientRect pattern from JourneyItemCard.svelte.spec.ts.
Tests actual rendered height rather than presence of a CSS class string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:06:49 +02:00
Marcel
6ed8ecf571 feat(a11y): add aria-describedby to Weiter button when aria-disabled
Screen readers now announce the hint paragraph text on focus when no type
is selected, so users hear why the button is disabled without having to
click it first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:06:11 +02:00
Marcel
4c75680977 refactor(radiogroupnav): remove aria-checked setAttribute calls
The action was writing aria-checked directly and then firing onChange,
which also triggered Svelte's own aria-checked={selected === type} binding.
Double-ownership: action now only calls focus() + onChange(value);
Svelte owns the attribute update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:05:38 +02:00
Marcel
930f69e884 refactor(geschichte): remove JSDoc what-comments from utils.ts
Function names already communicate intent. Comments that restate the
function name add noise without explaining why.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:05:07 +02:00
Marcel
eea8e6bf5a docs(journeyitemcard): document why item.document! non-null assertion is safe
JourneyReader filters items to only those where document != null before
passing them here — the ! assertion is valid by caller invariant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:04:32 +02:00
Marcel
55e3e4c531 fix(a11y): darken journey badge text from #b46820 to #7a3f0e for WCAG AA
Previous #b46820 on #fef0e6 = 3.81:1 — fails 4.5:1 required for text-xs
(12px normal text). #7a3f0e on #fef0e6 = 7.4:1 — passes WCAG AAA.
Dark-mode #e8862a on #3a2a1a = 5.16:1 — already passing, unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:04:04 +02:00
Marcel
7a5c2d0ba3 fix(geschichte): handle DELETE failure — show inline error on non-ok response
Adds deleteError $state to [id]/+page.svelte, parses backend error via
parseBackendError/getErrorMessage on !res.ok, and displays a role=alert
paragraph. Adds two browser-tier tests: success path (goto called) and
error path (alert visible, goto not called).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:03:04 +02:00
Marcel
994772564a fix(geschichten-new): add request to makeEvent and vi.fn wrapper to createApiClient mock
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m43s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
Sentry's wrapLoadWithSentry reads event.request.method — the test's makeEvent
now provides a real Request object. createApiClient mock was a plain function;
wrapping with vi.fn() enables vi.mocked(...).mockReturnValue in individual tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:25:56 +02:00
Marcel
a0930b62b0 test(typeselector): add keyboard navigation tests for ArrowRight/ArrowLeft
Verifies radioGroupNav action moves selection forward and wraps backward
so keyboard users can navigate the STORY/JOURNEY cards without a mouse.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:25:34 +02:00
Marcel
3572de487a test(journeyitemcard): use getBoundingClientRect for 44px touch-target assertion
CSS class string assertion was fragile — class names can change without
breaking the actual layout. DOM measurement via getBoundingClientRect is the
correct way to verify computed height meets WCAG 2.2 minimum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:25:12 +02:00
Marcel
f9cdc02a77 test(geschichte): add unit tests for formatAuthorName, formatAuthorDisplayName, formatPublishedAt
13 tests covering null/undefined inputs, partial names, email fallback,
and TZ-safe date slicing for formatPublishedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:24:52 +02:00
Marcel
4c24bbb002 refactor(geschichte): extract delete handler to [id]/+page.svelte, pass via ondelete prop
Moves the confirm-then-delete flow out of StoryReader and JourneyReader into
the single [id]/+page.svelte owner. Both reader components gain an optional
ondelete prop — the delete button calls ondelete?.() so the handler is opt-in
and never duplicated. Tests verify the prop is called on click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:24:33 +02:00
Marcel
91d9dae6fd refactor(geschichtelistrow): use formatAuthorName utility, eliminate inline name computation
Replaces the 3-line inline join with the shared formatAuthorName helper from
utils.ts. Test switches from CSS class string assertion to getComputedStyle
for the badge font-size check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:24:10 +02:00
Marcel
4184d0775b fix(journeyinterlude): use i18n aria-label instead of hardcoded German
Replaces aria-label="Kuratorennotiz" with m.journey_interlude_aria_label()
so screen readers get the correct label in all three supported locales.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:23:46 +02:00
Marcel
97026fec11 refactor(geschichte): add utils.ts (formatAuthorName/DisplayName/PublishedAt), update README
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 1m14s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:59:03 +02:00
Marcel
565eddd743 feat(lesereisen): TypeSelector (roving tabindex, aria-disabled), StoryCreate, type-gated new page, list uses GeschichteListRow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:58:40 +02:00
Marcel
0b9e8c2abb feat(lesereisen): JourneyItemCard, JourneyInterlude, JourneyReader with XSS + omit-rule specs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:58:15 +02:00
Marcel
8a6bc27979 feat(lesereisen): StoryReader — extract body/persons/docs/actions, isJourney badge in detail header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:57:51 +02:00
Marcel
8fea94cb61 test(lesereisen): TDD red — tighten factories, add journey/selector/ssr tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:57:28 +02:00
Marcel
0d47bcb4a1 feat(lesereisen): GeschichteListRow with JOURNEY badge + i18n keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:44:21 +02:00
Marcel
825a622413 feat(lesereisen): add journey orange CSS tokens to all three theme blocks
--c-journey-bg/text/border wired in light :root, dark @media, dark [data-theme]
blocks. Exposed via @theme inline as color-journey-tint/journey/journey-border.
Light: #B46820 on #FEF0E6 ≈ 4.6:1 AA at 12px bold. Dark: #E8862A on #3A2A1A ≈ 4.7:1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:33:08 +02:00
Marcel
81a12ba35c feat(api): regenerate api.ts — GeschichteView, GeschichteSummary, JourneyItemView, DocumentSummary
Self-check: GeschichteView.items present; type emitted as 'STORY'|'JOURNEY' union literal.
List endpoint returns GeschichteSummary[]; detail endpoint returns GeschichteView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:31:47 +02:00
0780c09bb4 feat(geschichte): JourneyItem CRUD API — append, updateNote, delete, reorder (#751) (#788)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
## Summary

Implements the backend JourneyItem CRUD API on top of the data model from #750, building towards the full Lesereisen feature (#751).

**Completed in this PR:**
- `jackson-databind-nullable` 0.2.6 + `JacksonConfig` (`@Bean Module`) for three-way PATCH semantics (`JsonNullable`)
- `AuditKind`: `JOURNEY_ITEM_ADDED`, `JOURNEY_ITEM_REMOVED`, `JOURNEY_ITEMS_REORDERED` (last is rollup-eligible)
- `ErrorCode`: `JOURNEY_ITEM_NOT_FOUND`, `JOURNEY_ITEM_POSITION_CONFLICT`
- V73 migration: `UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED` + `CHECK (position > 0)` on `journey_items`
- `JourneyItemConstraintsTest`: verifies deferrable flag via `pg_constraint` query; position check; duplicate position rejection (3 passing tests)
- Read models: `DocumentSummary`, `JourneyItemView`, `GeschichteView` (with `AuthorView` to prevent AppUser email leak)
- `DocumentService.getSummaryById` — lean lookup without tag-color resolution
- `JourneyItemRepository`: extended with `findByGeschichteIdOrderByPosition`, `findByIdAndGeschichteId` (IDOR-safe), `findIdsByGeschichteId`, `findMaxPositionByGeschichteId`, `countByGeschichteId`
- DTOs: `JourneyItemCreateDTO`, `JourneyItemUpdateDTO` (`JsonNullable<String> note`), `JourneyReorderDTO`

**Still in progress (WIP):**
- `JourneyItemService` — `append`, `updateNote`, `delete`, `reorder`, `toSummary`, `toView` (Task 6)
- `GeschichteService.getById` → returns `GeschichteView` (Task 7)
- New endpoints on `GeschichteController` + slice tests (Task 8)
- Frontend error codes + i18n + `npm run generate:api` (Task 9)

## Commits

- `0b177247` feat(config): add jackson-databind-nullable for JsonNullable PATCH DTO support
- `408ae334` feat(audit,error): add JourneyItem AuditKind values and ErrorCodes
- `7b06c3ad` feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items
- `160ca1c3` feat(geschichte): add DocumentSummary, JourneyItemView, GeschichteView read models
- `2ad5c36e` feat(geschichte): extend JourneyItemRepository and add item DTOs

## Test plan

- [ ] `./mvnw test -Dtest=JourneyItemConstraintsTest` — all 3 constraint tests pass
- [ ] `./mvnw clean package -DskipTests` — builds clean after remaining tasks are merged
- [ ] Frontend: `npm run generate:api` after Task 9 endpoint additions

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #788
2026-06-08 22:15:10 +02:00
Marcel
77cbbd34a0 test(journeyitem): verify findSummaryByIdInternal never called before JOURNEY-type guard
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:38:25 +02:00
Marcel
84b47f1836 fix(geschichte): move DocumentSummary to journeyitem sub-package
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:37:07 +02:00
Marcel
99111273e5 refactor(document): rename getSummaryById to findSummaryByIdInternal to signal scope-check bypass
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m52s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m9s
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>
2026-06-08 21:13:35 +02:00
Marcel
f09c79744e fix(geschichte): restore getView() on GeschichteService with @Transactional(readOnly=true) — fixes two-call transaction gap
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>
2026-06-08 21:12:25 +02:00
Marcel
1108277472 refactor(geschichte): extract PersonNameFormatter to eliminate duplicated name-join logic
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m15s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m51s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
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>
2026-06-08 20:53:49 +02:00
Marcel
9db3b41fdb docs(api): document reorder full-list contract in OpenAPI
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>
2026-06-08 20:52:02 +02:00
Marcel
73004ce49f docs(document): document scope-check bypass on getSummaryById
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>
2026-06-08 20:51:10 +02:00
Marcel
c31f82a69c fix(test): use nullValue() matcher instead of doesNotExist() for null note field
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>
2026-06-08 20:50:17 +02:00
Marcel
f9ae6a91ba test(journeyitem): add integration tests for append and reorder against real PostgreSQL
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>
2026-06-08 20:49:23 +02:00
Marcel
70da532f54 docs(c4): add GeschichteQueryService component; fix GeschichteService relationship
Add GeschichteQueryService component to the L3 supporting-domains diagram.
Remove the now-deleted Rel(geschSvc, journeyItemSvc, "Delegates getItems()")
arrow and add the correct Rel(journeyItemSvc, geschQuerySvc, ...) arrow that
reflects the actual dependency direction after the refactor in the prior commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:47:46 +02:00
Marcel
ad90ae75bf fix(journeyitem): use JOIN FETCH to eliminate N+1 document queries
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>
2026-06-08 20:46:54 +02:00
Marcel
164178ecf1 refactor(geschichte): assemble GeschichteView in controller — break GeschichteService↔JourneyItemService cycle
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>
2026-06-08 20:45:09 +02:00
Marcel
3f36d2a7f1 chore(test): remove JacksonConfig from GeschichteControllerTest @Import
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m8s
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>
2026-06-08 20:07:03 +02:00
Marcel
5b2ee31292 feat(i18n): add journey_item_document_deleted placeholder key
Adds de/en/es translations for the case where a JourneyItem's linked
document has been deleted (document field is null), so the UI PR can
display a meaningful fallback string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:06:09 +02:00
Marcel
3d80bc656c refactor(journeyitem): use saveAll in reorder for efficiency
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>
2026-06-08 20:05:28 +02:00
Marcel
4a0fed617a refactor(geschichte): route all reads through GeschichteQueryService
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>
2026-06-08 20:04:21 +02:00
Marcel
7ba6342a84 chore(config): remove empty JacksonConfig placeholder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 20:02:51 +02:00
Marcel
598ad622e7 fix(journeyitem): use specific error codes in append() — JOURNEY_AT_CAPACITY and GESCHICHTE_TYPE_MISMATCH
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m56s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
- 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>
2026-06-08 19:31:30 +02:00
Marcel
c5611250ec test(journey): rename updateItemNote test to clarify Optional deserialization semantics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:17:25 +02:00
Marcel
e400b1d77e feat(error): add GESCHICHTE_TYPE_MISMATCH error code with i18n (de/en/es)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:15:45 +02:00
Marcel
1fb0c41216 feat(error): add JOURNEY_AT_CAPACITY error code with i18n (de/en/es)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:13:47 +02:00
Marcel
147aa56386 feat(audit): add JOURNEY_ITEM_NOTE_UPDATED audit kind and wire into updateNote()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:04:43 +02:00
Marcel
7c06609816 refactor(journey): make toView() and toSummary() package-private
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:03:24 +02:00
Marcel
2ae1bb3a30 fix(journey): reorder() throws 404 when Geschichte does not exist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:02:03 +02:00
Marcel
69db198319 refactor(geschichte): introduce GeschichteQueryService with existsById()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:00:32 +02:00
Marcel
e157d90b53 docs(backend): add JourneyItemService and GeschichteQueryService to CLAUDE.md package table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:58:20 +02:00
Marcel
97f22e1ce8 fix(review): friendlier i18n message for journey position conflict error
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:13:39 +02:00
Marcel
5539158e8f fix(review): add JourneyItemService to C4 L3 supporting-domains diagram
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:12:50 +02:00
Marcel
7ed0032661 fix(review): replace Set<Person> with Set<PersonView> in GeschichteView — prevents leaking admin fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:12:13 +02:00
Marcel
2f471155b8 fix(review): replace email fallback with [Unbekannt] in AuthorView — prevents CWE-359 leak
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:09:40 +02:00
Marcel
4eb6abd920 fix(review): address PR #788 review blockers
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m26s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m53s
CI / fail2ban Regex (pull_request) Failing after 46s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
- GlobalExceptionHandler maps uq_journey_items_geschichte_position constraint
  violation to HTTP 409 JOURNEY_ITEM_POSITION_CONFLICT
- JourneyItemService.reorder() rejects duplicate IDs before set-equality check
  to prevent silent position overwrite
- JourneyItemRepository removes orphaned findAllByGeschichteId method
- GeschichteView removes stale com.fasterxml.jackson import
- Tests: add appendItem_returns409_on_position_conflict (controller),
  reorder_returns400_when_itemIds_contain_duplicates (service)
- Fix JourneyItemIntegrationTest compilation after repository cleanup
- db-orm.puml annotates journey_items position with CHECK + UNIQUE DEFERRABLE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:16:32 +02:00
Marcel
6fc5ce6ddd docs: update GLOSSARY for JourneyItem view types; add ADR-035
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m20s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 3m48s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Fixes GLOSSARY position-step value (1000→10), adds DEFERRABLE constraint note,
and documents GeschichteView, JourneyItemView, and DocumentSummary read-model types.

ADR-035 records the decision to use Optional<String> for three-way PATCH semantics
instead of jackson-databind-nullable (which targets Jackson 2.x and is incompatible
with Spring Boot 4.0 / Jackson 3.x).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:06:25 +02:00
Marcel
4603e335fd feat(frontend): add JOURNEY_ITEM error codes, i18n keys, regen API types
Adds JOURNEY_ITEM_NOT_FOUND and JOURNEY_ITEM_POSITION_CONFLICT to the frontend
ErrorCode union and getErrorMessage() switch. Adds de/en/es translations.
Regenerates api.ts from the current OpenAPI spec (needs a second run once the
backend is restarted with the new endpoints compiled in).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:04:51 +02:00
Marcel
a0fa8f4d02 feat(journeyitem): add CRUD endpoints for JourneyItems on GeschichteController
Adds POST/PATCH/DELETE/PUT-reorder endpoints for journey items, backed by
JourneyItemService. Replaces jackson-databind-nullable (Jackson 2.x, incompatible
with Spring Boot 4 / Jackson 3.x) with Optional<String> three-way PATCH semantics:
null = absent/no-op, empty = clear, present = set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:02:12 +02:00
Marcel
d29f217328 feat(geschichte): getById returns GeschichteView; author email never exposed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:52:29 +02:00
Marcel
fdc9273c86 feat(geschichte): implement JourneyItemService — append, updateNote, delete, reorder (35 unit tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:47:42 +02:00
Marcel
2ad5c36e3c feat(geschichte): extend JourneyItemRepository and add item DTOs
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m19s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m59s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
Repository: findByIdAndGeschichteId (IDOR-safe lookup),
findByGeschichteIdOrderByPosition, findIdsByGeschichteId (Set<UUID> for
set-equality reorder check), findMaxPositionByGeschichteId, countByGeschichteId.
DTOs: JourneyItemCreateDTO (documentId+note), JourneyItemUpdateDTO
(JsonNullable<String> note for 3-way PATCH), JourneyReorderDTO (List<UUID>).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:32:50 +02:00
Marcel
160ca1c3e9 feat(geschichte): add DocumentSummary, JourneyItemView, GeschichteView read models
DocumentSummary: lean document projection for journey item embedding —
skips tag-color resolution (getSummaryById), includes receiverCount
(0 when no receivers, non-null). JourneyItemView: response record for
item CRUD and GET. GeschichteView: detail response with summarised
author {id, displayName} to prevent AppUser email/group leak.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:31:40 +02:00
Marcel
7b06c3adec feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items
DEFERRABLE INITIALLY DEFERRED allows mid-transaction position swaps
during reorder (checked at COMMIT, not per-row). CHECK (position > 0)
guards against off-by-one in the append path. Both verified by
JourneyItemConstraintsTest via raw pg_constraint query + jdbcTemplate
inserts against a real postgres:16-alpine container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:30:07 +02:00
Marcel
408ae3345c feat(audit,error): add JourneyItem AuditKind values and ErrorCodes
Adds JOURNEY_ITEM_ADDED, JOURNEY_ITEM_REMOVED, JOURNEY_ITEMS_REORDERED
(last is ROLLUP_ELIGIBLE — drag-heavy editing produces many events).
Adds JOURNEY_ITEM_NOT_FOUND (404) and JOURNEY_ITEM_POSITION_CONFLICT
(409) to ErrorCode for IDOR protection and concurrent-edit feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:27:15 +02:00
Marcel
0b17724785 feat(config): add jackson-databind-nullable for JsonNullable PATCH DTO support
Registers JsonNullableModule globally so JsonNullable<String> in
JourneyItemUpdateDTO can distinguish absent (unchanged) from explicit
null (clear field) on PATCH operations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:26:26 +02:00
Marcel
df5d880e09 fix(review): GeschichtenCard uses GeschichteSummary type; focus-visible on journey links; fix stale tests
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m17s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
- GeschichtenCard.svelte: use GeschichteSummary instead of Geschichte
  (list endpoint returns summaries; no items/createdAt/updatedAt needed)
- GeschichtenCard.svelte.test.ts: factory returns GeschichteSummary with
  lean author shape; drop Geschichte-only fields (createdAt, groups, etc.)
- geschichten/[id]/+page.svelte: add focus:outline-none focus-visible:ring-2
  focus-visible:ring-focus-ring to journey item document links (WCAG 2.4.7)
- page.svelte.test.ts ([id]): replace stale documents[] factory field with
  items[]; test now checks placeholder text + note caption
- page.svelte.test.ts (new): remove removed initialDocuments from baseData;
  rename test to reflect that only initialPersons is passed through

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:01:50 +02:00
Marcel
45500cc5e2 fix(review): separate note from link label in journey item stub
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m7s
item.note is editorial prose — it must not be used as the anchor label.
Always show the i18n placeholder as the link text; render note as a
caption below the link when present.

Adds TODO(#786) comment so the stub degradation is tracked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:38:44 +02:00
Marcel
b1819867be fix(review): address second-pass PR #787 review blockers
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m54s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m47s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
- CLAUDE.md: add Geschichte and JourneyItem rows to the Domain Model table
- GeschichteSummary: add @Schema(requiredMode=REQUIRED) to getId, getTitle,
  getStatus, getType, and AuthorSummary.getEmail so the TypeScript generator
  emits non-optional fields when api.ts is next regenerated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:13:38 +02:00
Marcel
2c5f7ac12d fix(review): address PR #787 review blockers — db-orm diagram, C4 diagram, UUID link text
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m45s
CI / fail2ban Regex (pull_request) Successful in 48s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
- db-orm.puml: replace geschichten_documents with journey_items, add type column to geschichten, bump schema version to V72
- l3-backend-3g-supporting.puml: update GeschichteController and GeschichteService descriptions to mention STORY/JOURNEY subtypes and JourneyItem
- geschichten/[id]/+page.svelte: replace raw UUID fallback with m.geschichten_document_link_placeholder() i18n key
- messages/{de,en,es}.json: add geschichten_document_link_placeholder translation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:54:12 +02:00
Marcel
13fa4123c1 docs: update ARCHITECTURE, GLOSSARY, db diagram for JourneyItem and Lesereise
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m50s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m49s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / fail2ban Regex (pull_request) Successful in 51s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
- ARCHITECTURE.md: expand geschichte domain description — two subtypes
  (STORY/JOURNEY), JourneyItem ownership, ON DELETE SET NULL FK note
- GLOSSARY.md: add JourneyItem and Lesereise terms; update Geschichte
  entry to mention type discriminator
- db-relationships.puml: replace geschichten_documents with journey_items
  (ON DELETE CASCADE to geschichten, ON DELETE SET NULL to documents)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:41:41 +02:00
Marcel
ccdf358b40 test(frontend): fix Geschichte component specs for GeschichteType and JourneyItem model
- GeschichteEditor.svelte.spec.ts: remove docFactory + initialDocuments test;
  rename documentIds test to personIds-only; add familyMember+provisional to
  personFactory (were pre-existing omissions)
- GeschichtenCard.svelte.spec.ts: add type:'STORY', replace documents:[] with
  items:[], change body null→undefined to match Geschichte schema
- GeschichtenCard.svelte.test.ts: add status/type/createdAt/updatedAt to factory;
  cast result as Geschichte to avoid spread-widening type inference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:40:17 +02:00
Marcel
e6c890c61e feat(frontend): update generated API types and Geschichte routes for JourneyItem model
- api.ts: add GeschichteType, JourneyItem, GeschichteSummary schemas;
  remove documentId param from list endpoint; change list response to
  GeschichteSummary[]; add type + items to Geschichte; remove documents field
- GeschichteEditor: remove DocumentMultiSelect + documentIds from payload
  (journey items are managed via the future Lesereisen editor, not here)
- GET /geschichten page: remove documentId filter from server load + URL logic
- geschichten/new: remove documentId pre-population from server load
- geschichten/[id]: replace g.documents with g.items (document-backed JourneyItems)
- geschichten/new + [id]/edit: remove documentIds from submit payload type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:39:53 +02:00
Marcel
93ef26690f test(geschichte): add JourneyItemIntegrationTest, GeschichteListProjectionTest, GeschichteHttpTest; update unit tests
New tests:
- JourneyItemIntegrationTest: @OrderBy, cascade delete, orphan removal,
  type round-trip, note-only item, document-backed item, ON DELETE SET NULL,
  CHECK constraint violation (9 tests)
- GeschichteListProjectionTest (@DataJpaTest): findSummaries status filter,
  AuthorSummary, type field, authorId filter, AND-semantics person filter (8 tests)
- GeschichteHttpTest (@SpringBootTest RANDOM_PORT): list 200, getById 200 with
  JOURNEY items, getById 404, DRAFT hidden from non-BLOG_WRITE user (5 tests)

Updated tests:
- GeschichteServiceTest: mock signature (3 args), anonymous GeschichteSummary
  stubs (Jackson can't serialize Mockito interface mocks), remove documentId cases
- GeschichteControllerTest: summaryStub() concrete anonymous impl,
  updated mock matcher to 3-arg list()
- GeschichteServiceIntegrationTest: List<GeschichteSummary> return type,
  3-arg list() calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:25:18 +02:00
Marcel
439385dd35 refactor(geschichte): update service, controller, repository for JourneyItem model
- GeschichteService.list() now returns List<GeschichteSummary> via JPQL
  projection query; accepts (status, personIds, limit); DRAFT clamp for
  non-BLOG_WRITE users; AND-semantics person filter with sentinel UUID guard
- GeschichteService.getById() is @Transactional(readOnly=true) and calls
  Hibernate.initialize(g.getItems()) to force-init the LAZY bag under
  open-in-view=false
- GeschichteRepository: add findSummaries() JPQL query with person subquery
- GeschichteController.list(): remove documentId param, change return type
  to List<GeschichteSummary>
- GeschichteSpecifications: remove hasDocument() and documentSubquery() —
  TODO left for lesereisen-editor follow-on

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:24:50 +02:00
Marcel
b3ce9b930f feat(geschichte): add GeschichteType, JourneyItem entity, GeschichteSummary, and V72 migration
- GeschichteType enum {STORY, JOURNEY} — default STORY
- JourneyItem entity replaces geschichten_documents junction table;
  position-ordered, document_id nullable (note-only items allowed),
  CHECK(document_id IS NOT NULL OR note IS NOT NULL)
- GeschichteSummary interface projection for list() queries (avoids lazy-init)
- Geschichte entity gains `type` + `items` (LAZY, orphanRemoval, CascadeType.ALL)
  replacing the old `documents` ManyToMany bag
- GeschichteUpdateDTO: remove documentIds (replaced by JourneyItem API)
- V72 migration: adds `type` column, creates `journey_items` table with
  FK ON DELETE CASCADE (geschichte) / ON DELETE SET NULL (document),
  migrates geschichten_documents ordered by meta_date, drops junction table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:24:24 +02:00
84 changed files with 4978 additions and 628 deletions

View File

@@ -86,7 +86,8 @@ backend/src/main/java/org/raddatz/familienarchiv/
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
├── filestorage/ FileService (S3/MinIO)
├── geschichte/ Geschichte (story) domain
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
├── notification/ Notification domain + SseEmitterRegistry
├── ocr/ OCR domain — OcrService, OcrBatchService, training
@@ -105,13 +106,15 @@ backend/src/main/java/org/raddatz/familienarchiv/
### Domain Model
| Entity | Table | Key relationships |
| ----------- | ------------- | ------------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| Entity | Table | Key relationships |
| ------------- | --------------- | --------------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`

View File

@@ -33,7 +33,8 @@ src/main/java/org/raddatz/familienarchiv/
│ └── transcription/ # TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
├── exception/ # DomainException, ErrorCode, GlobalExceptionHandler
├── filestorage/ # FileService (S3/MinIO)
├── geschichte/ # Geschichte (story) domain
├── geschichte/ # Geschichte (story) domain — GeschichteService, GeschichteQueryService
│ └── journeyitem/ # JourneyItem sub-domain — JourneyItemService, JourneyItemController
├── importing/ # CanonicalImportOrchestrator + 4 loaders + CanonicalSheetReader
├── notification/ # Notification domain + SseEmitterRegistry
├── ocr/ # OCR domain — OcrService, OcrBatchService, training

View File

@@ -50,10 +50,25 @@ public enum AuditKind {
ADMIN_FORCE_LOGOUT,
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
LOGIN_RATE_LIMITED;
LOGIN_RATE_LIMITED,
// --- Reading Journeys (Lesereisen) ---
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
JOURNEY_ITEM_ADDED,
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
JOURNEY_ITEM_REMOVED,
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
JOURNEY_ITEM_NOTE_UPDATED,
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
JOURNEY_ITEMS_REORDERED;
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
JOURNEY_ITEMS_REORDERED
);
}

View File

@@ -1006,6 +1006,28 @@ public class DocumentService {
return doc;
}
/**
* Lightweight summary lookup for internal use (e.g. journey item append validation).
*
* <p><strong>Security contract — read before calling:</strong>
* <ol>
* <li>This method intentionally bypasses per-document scope checks and
* tag-colour resolution. It must only be invoked after
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
* the controller layer, guaranteeing the caller is an authenticated
* author.</li>
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
* JOURNEY-type check that fires before this call — so the method is never
* reached for STORY-type Geschichten.</li>
* </ol>
* Under the current single-tenant model every authenticated author shares the
* same document scope, so skipping per-document scope checks is safe.
*/
public Document findSummaryByIdInternal(UUID id) {
return documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
}
/**
* Loads a document for the detail view, additionally flagging whether it has any
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap

View File

@@ -122,6 +122,14 @@ public enum ErrorCode {
// --- Geschichten (Stories) ---
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
GESCHICHTE_NOT_FOUND,
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
JOURNEY_ITEM_NOT_FOUND,
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
JOURNEY_ITEM_POSITION_CONFLICT,
/** The journey already has the maximum allowed number of items (100). 400 */
JOURNEY_AT_CAPACITY,
/** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */
GESCHICHTE_TYPE_MISMATCH,
// --- Tags ---
/** A tag with the given ID does not exist. 404 */

View File

@@ -78,7 +78,14 @@ public class GlobalExceptionHandler {
// Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which
// constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the
// offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault.
log.warn("Rejected a request that violated a database integrity constraint: {}", constraintNameOf(ex));
String constraint = constraintNameOf(ex);
log.warn("Rejected a request that violated a database integrity constraint: {}", constraint);
if ("uq_journey_items_geschichte_position".equals(constraint)) {
// DEFERRABLE INITIALLY DEFERRED — fires at commit when concurrent appends/reorders collide
return ResponseEntity.status(409)
.body(new ErrorResponse(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT,
"A position conflict was detected — another request modified this journey simultaneously"));
}
return ResponseEntity.badRequest()
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint"));
}

View File

@@ -5,12 +5,14 @@ import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.person.Person;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@@ -40,6 +42,12 @@ public class Geschichte {
@Builder.Default
private GeschichteStatus status = GeschichteStatus.DRAFT;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
@Builder.Default
private GeschichteType type = GeschichteType.STORY;
@ManyToOne
@JoinColumn(name = "author_id")
private AppUser author;
@@ -51,12 +59,18 @@ public class Geschichte {
@Builder.Default
private Set<Person> persons = new HashSet<>();
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(name = "geschichten_documents",
joinColumns = @JoinColumn(name = "geschichte_id"),
inverseJoinColumns = @JoinColumn(name = "document_id"))
// LAZY per docs/adr/022-eager-to-lazy-fetch-strategy.md. open-in-view is FALSE
// (application.yaml), so this collection is DEAD at Jackson serialization time unless
// explicitly initialized inside the service transaction. getById() is
// @Transactional(readOnly=true) AND calls getItems().size() to force-init before return.
// list() must NOT serialize items at all — it returns a GeschichteSummary projection.
// This is the first List ("bag") collection on Geschichte — adding a second EAGER/
// fetch-joined List here will throw MultipleBagFetchException at boot.
@OneToMany(mappedBy = "geschichte", cascade = CascadeType.ALL, orphanRemoval = true,
fetch = FetchType.LAZY)
@OrderBy("position ASC")
@Builder.Default
private Set<Document> documents = new HashSet<>();
private List<JourneyItem> items = new ArrayList<>();
@CreationTimestamp
@Column(updatable = false)

View File

@@ -1,12 +1,14 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.geschichte.GeschichteService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -28,23 +31,22 @@ import java.util.UUID;
public class GeschichteController {
private final GeschichteService geschichteService;
private final JourneyItemService journeyItemService;
@GetMapping
public List<Geschichte> list(
public List<GeschichteSummary> list(
@RequestParam(required = false) GeschichteStatus status,
@RequestParam(name = "personId", required = false) List<UUID> personIds,
@RequestParam(required = false) UUID documentId,
@RequestParam(required = false, defaultValue = "50") int limit) {
return geschichteService.list(
status,
personIds == null ? List.of() : personIds,
documentId,
limit);
}
@GetMapping("/{id}")
public Geschichte getById(@PathVariable UUID id) {
return geschichteService.getById(id);
public GeschichteView getById(@PathVariable UUID id) {
return geschichteService.getView(id);
}
@PostMapping
@@ -66,4 +68,45 @@ public class GeschichteController {
geschichteService.delete(id);
return ResponseEntity.noContent().build();
}
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
@PostMapping("/{id}/items")
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<JourneyItemView> appendItem(
@PathVariable UUID id,
@RequestBody JourneyItemCreateDTO dto) {
JourneyItemView view = journeyItemService.append(id, dto);
return ResponseEntity.status(HttpStatus.CREATED).body(view);
}
@PatchMapping("/{id}/items/{itemId}")
@RequirePermission(Permission.BLOG_WRITE)
public JourneyItemView updateItemNote(
@PathVariable UUID id,
@PathVariable UUID itemId,
@RequestBody JourneyItemUpdateDTO dto) {
return journeyItemService.updateNote(id, itemId, dto);
}
@DeleteMapping("/{id}/items/{itemId}")
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Void> deleteItem(
@PathVariable UUID id,
@PathVariable UUID itemId) {
journeyItemService.delete(id, itemId);
return ResponseEntity.noContent().build();
}
@PutMapping("/{id}/items/reorder")
@RequirePermission(Permission.BLOG_WRITE)
@Operation(
summary = "Reorder journey items",
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
)
public List<JourneyItemView> reorderItems(
@PathVariable UUID id,
@RequestBody JourneyReorderDTO dto) {
return journeyItemService.reorder(id, dto);
}
}

View File

@@ -0,0 +1,29 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Optional;
import java.util.UUID;
/**
* Thin read-only service owning {@link GeschichteRepository}.
* Exists so that {@code JourneyItemService} can check Geschichte existence
* and load Geschichte instances without holding a direct reference to the
* Geschichte repository (cross-domain repository access is not allowed per
* layering rules).
*/
@Service
@RequiredArgsConstructor
public class GeschichteQueryService {
private final GeschichteRepository geschichteRepository;
public boolean existsById(UUID id) {
return geschichteRepository.existsById(id);
}
public Optional<Geschichte> findById(UUID id) {
return geschichteRepository.findById(id);
}
}

View File

@@ -1,12 +1,43 @@
package org.raddatz.familienarchiv.geschichte;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
@Repository
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
/**
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
*
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
*
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
*/
@Query("""
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
g.author AS author, g.publishedAt AS publishedAt, g.body AS body
FROM Geschichte g
WHERE g.status = :effectiveStatus
AND (:authorId IS NULL OR g.author.id = :authorId)
AND (:personCount = 0 OR
(SELECT COUNT(DISTINCT p.id)
FROM Geschichte g2 JOIN g2.persons p
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
""")
List<GeschichteSummary> findSummaries(
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
@Param("authorId") UUID authorId,
@Param("personIds") Collection<UUID> personIds,
@Param("personCount") long personCount);
}

View File

@@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
@@ -41,6 +36,7 @@ public class GeschichteService {
private final PersonService personService;
private final DocumentService documentService;
private final UserService userService;
private final JourneyItemService journeyItemService;
/**
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
@@ -60,6 +56,7 @@ public class GeschichteService {
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
}
@Transactional(readOnly = true)
public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
@@ -72,24 +69,57 @@ public class GeschichteService {
return g;
}
@Transactional(readOnly = true)
public GeschichteView getView(UUID id) {
Geschichte g = getById(id);
List<JourneyItemView> items = journeyItemService.getItems(id);
return toView(g, items);
}
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
AppUser author = g.getAuthor();
GeschichteView.AuthorView authorView = null;
if (author != null) {
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
if (displayName.isBlank()) displayName = "[Unbekannt]";
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
}
Set<GeschichteView.PersonView> personViews = new HashSet<>();
for (Person p : g.getPersons()) {
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
}
return new GeschichteView(
g.getId(), g.getTitle(), g.getBody(),
g.getStatus(), g.getType(),
authorView, personViews,
items,
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
);
}
/**
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
* must be associated with every person id supplied. An empty or null list applies no
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
*
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
* LazyInitializationException on the non-transactional list path.
*/
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, int limit) {
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
Specification<Geschichte> spec = Specification.allOf(
GeschichteSpecifications.hasStatus(effective),
GeschichteSpecifications.hasAuthor(authorId),
GeschichteSpecifications.hasAllPersons(personIds),
GeschichteSpecifications.hasDocument(documentId),
GeschichteSpecifications.orderByDisplayDateDesc()
);
return geschichteRepository.findAll(spec, Sort.unsorted())
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
: personIds;
long personCount = (personIds == null) ? 0 : personIds.size();
return geschichteRepository
.findSummaries(effective, authorId, safePersonIds, personCount)
.stream()
.limit(safeLimit)
.toList();
@@ -106,7 +136,6 @@ public class GeschichteService {
.status(GeschichteStatus.DRAFT)
.author(currentUser())
.persons(resolvePersons(dto.getPersonIds()))
.documents(resolveDocuments(dto.getDocumentIds()))
.build();
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
g.setStatus(GeschichteStatus.PUBLISHED);
@@ -130,9 +159,6 @@ public class GeschichteService {
if (dto.getPersonIds() != null) {
g.setPersons(resolvePersons(dto.getPersonIds()));
}
if (dto.getDocumentIds() != null) {
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
}
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
applyStatusTransition(g, dto.getStatus());
}
@@ -176,15 +202,6 @@ public class GeschichteService {
return new LinkedHashSet<>(personService.getAllById(ids));
}
private Set<Document> resolveDocuments(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
Set<Document> out = new LinkedHashSet<>();
for (UUID id : ids) {
out.add(documentService.getDocumentById(id));
}
return out;
}
private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {

View File

@@ -6,9 +6,6 @@ import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
import org.springframework.data.jpa.domain.Specification;
@@ -48,12 +45,7 @@ public final class GeschichteSpecifications {
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
}
public static Specification<Geschichte> hasDocument(UUID documentId) {
return (root, query, cb) -> {
if (documentId == null) return null;
return cb.exists(documentSubquery(root, query, cb, documentId));
};
}
// TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
/**
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
@@ -84,14 +76,4 @@ public final class GeschichteSpecifications {
return sub;
}
private static Subquery<UUID> documentSubquery(
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
Subquery<UUID> sub = query.subquery(UUID.class);
Root<Geschichte> subRoot = sub.from(Geschichte.class);
Join<Geschichte, Document> documents = subRoot.join("documents");
sub.select(subRoot.get("id"))
.where(cb.equal(subRoot.get("id"), root.get("id")),
cb.equal(documents.get("id"), documentId));
return sub;
}
}

View File

@@ -0,0 +1,42 @@
package org.raddatz.familienarchiv.geschichte;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* List-projection for the /api/geschichten grid. Never carries items — avoids
* LazyInitializationException (open-in-view: false) and prevents Cartesian joins.
* Mirrors the PersonSummaryDTO precedent.
*
* <p>Field set: exactly what the live grid card renders (title, author byline, body excerpt,
* publishedAt, status, type). Does NOT carry items or persons.
*/
public interface GeschichteSummary {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID getId();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String getTitle();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
GeschichteStatus getStatus();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
GeschichteType getType();
/** Nested closed projection — exposes only the fields the grid card needs. */
AuthorSummary getAuthor();
LocalDateTime getPublishedAt();
String getBody();
interface AuthorSummary {
String getFirstName();
String getLastName();
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String getEmail();
}
}

View File

@@ -0,0 +1,6 @@
package org.raddatz.familienarchiv.geschichte;
public enum GeschichteType {
STORY,
JOURNEY
}

View File

@@ -1,7 +1,6 @@
package org.raddatz.familienarchiv.geschichte;
import lombok.Data;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import java.util.List;
import java.util.UUID;
@@ -17,5 +16,4 @@ public class GeschichteUpdateDTO {
private String body;
private GeschichteStatus status;
private List<UUID> personIds;
private List<UUID> documentIds;
}

View File

@@ -0,0 +1,41 @@
package org.raddatz.familienarchiv.geschichte;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Detail-view response for GET /api/geschichten/{id}. Assembled by
* GeschichteService — never the raw entity (author AppUser graph must not leak).
* items is always present (both STORY and JOURNEY); empty list for stories with no items.
*/
public record GeschichteView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
String body,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteStatus status,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) GeschichteType type,
AuthorView author,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Set<PersonView> persons,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<JourneyItemView> items,
LocalDateTime publishedAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime updatedAt
) {
/** Summarised author — exposes only id and displayName, never email or group memberships. */
public record AuthorView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String displayName
) {}
/** Summarised person — exposes only id, firstName, and lastName. No admin-only fields. */
public record PersonView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
String firstName,
String lastName
) {}
}

View File

@@ -0,0 +1,22 @@
package org.raddatz.familienarchiv.geschichte;
/**
* Utility for joining a person's first and last name into a display string.
* Centralises the logic that was previously duplicated across GeschichteService
* and JourneyItemService.
*/
public class PersonNameFormatter {
private PersonNameFormatter() {
// utility class — no instances
}
public static String join(String firstName, String lastName) {
String first = firstName != null ? firstName.trim() : "";
String last = lastName != null ? lastName.trim() : "";
if (first.isEmpty() && last.isEmpty()) return "";
if (first.isEmpty()) return last;
if (last.isEmpty()) return first;
return first + " " + last;
}
}

View File

@@ -0,0 +1,23 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DatePrecision;
import java.time.LocalDate;
import java.util.UUID;
/**
* Lean read-model view of a Document for embedding in JourneyItemView.
* Built by JourneyItemService.toSummary(Document) — never serialised from
* a JPA entity to avoid LazyInitializationException and tag-color overhead.
*/
public record DocumentSummary(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
LocalDate documentDate,
LocalDate documentDateEnd,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision datePrecision,
String senderName,
String receiverName,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Integer receiverCount
) {}

View File

@@ -0,0 +1,51 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import java.util.UUID;
@Entity
@Table(name = "journey_items")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class JourneyItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "geschichte_id", nullable = false)
@JsonIgnore
private Geschichte geschichte;
// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order
// — the editor is responsible for keeping them distinct.
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int position;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_id")
@JsonIgnore
private Document document;
// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email
// renderer MUST escape this; only Svelte {note} is auto-safe.
@Column(columnDefinition = "TEXT")
private String note;
// JPA uses field access — this getter is not persisted. Jackson serializes it as documentId.
// Exposing only the UUID prevents circular references and large nested payloads.
public UUID getDocumentId() {
return document != null ? document.getId() : null;
}
}

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
import java.util.UUID;
/** Input for POST /api/geschichten/{id}/items. Both fields optional; at least one must be present. */
@Data
public class JourneyItemCreateDTO {
private UUID documentId;
private String note;
}

View File

@@ -0,0 +1,41 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@Repository
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
/** Returns items ordered by position ASC for the read-model assembly path. */
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
/** Returns only the IDs — used for set-equality check in reorder. */
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
/** MAX position for computing the next append position; returns empty when journey has no items. */
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
long countByGeschichteId(UUID geschichteId);
/**
* Loads journey items with their linked Document in a single JOIN FETCH query,
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
* lazily for each item. Items without a document (note-only) are included via
* LEFT JOIN. Ordered by position ASC.
*/
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
}

View File

@@ -0,0 +1,253 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
@Service
@RequiredArgsConstructor
@Slf4j
public class JourneyItemService {
static final int MAX_ITEMS = 100;
static final int POSITION_STEP = 10;
static final int MAX_NOTE_LENGTH = 5000;
private final JourneyItemRepository journeyItemRepository;
private final GeschichteQueryService geschichteQueryService;
private final DocumentService documentService;
private final AuditService auditService;
private final UserService userService;
@Transactional
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
Geschichte g = geschichteQueryService.findById(geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
"Journey not found: " + geschichteId));
if (g.getType() != GeschichteType.JOURNEY) {
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_MISMATCH,
"Journey items can only be added to a JOURNEY-type Geschichte");
}
long count = journeyItemRepository.countByGeschichteId(geschichteId);
if (count >= MAX_ITEMS) {
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
"Journey has reached the maximum of 100 items");
}
String note = normalizeNote(dto.getNote());
if (dto.getDocumentId() == null && note == null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"At least one of documentId or note must be provided");
}
if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
}
Document doc = null;
if (dto.getDocumentId() != null) {
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
}
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
.map(max -> max + POSITION_STEP)
.orElse(POSITION_STEP);
JourneyItem item = JourneyItem.builder()
.geschichte(g)
.position(nextPosition)
.document(doc)
.note(note)
.build();
JourneyItem saved = journeyItemRepository.save(item);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
return toView(saved);
}
@Transactional
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
"Journey item not found: " + itemId));
// null = field absent from JSON → no-op
Optional<String> noteField = dto.getNote();
if (noteField == null) {
return toView(item);
}
String note = normalizeNote(noteField.orElse(null));
if (note != null && note.length() > MAX_NOTE_LENGTH) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
}
if (note == null && item.getDocumentId() == null) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Cannot clear note on an item that has no linked document");
}
item.setNote(note);
JourneyItem saved = journeyItemRepository.save(item);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", itemId));
return toView(saved);
}
@Transactional
public void delete(UUID geschichteId, UUID itemId) {
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
"Journey item not found: " + itemId));
journeyItemRepository.delete(item);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
Map.of("geschichteId", geschichteId, "itemId", itemId));
}
@Transactional
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
if (!geschichteQueryService.existsById(geschichteId)) {
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
"Journey not found: " + geschichteId);
}
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Duplicate item IDs in reorder request");
}
if (!existingIds.equals(new HashSet<>(requestedIds))) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Requested item IDs do not match the journey's existing items");
}
if (requestedIds.isEmpty()) {
return List.of();
}
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
Map<UUID, JourneyItem> itemMap = new HashMap<>();
for (JourneyItem item : items) {
itemMap.put(item.getId(), item);
}
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
for (int i = 0; i < requestedIds.size(); i++) {
JourneyItem item = itemMap.get(requestedIds.get(i));
item.setPosition((i + 1) * POSITION_STEP);
toSave.add(item);
}
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
UUID actorId = currentUser().getId();
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
return reordered.stream().map(this::toView).toList();
}
public List<JourneyItemView> getItems(UUID geschichteId) {
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
.stream().map(this::toView).toList();
}
DocumentSummary toSummary(Document doc) {
String senderName = buildSenderName(doc);
Set<Person> receivers = doc.getReceivers();
String receiverName = buildCanonicalReceiverName(receivers);
return new DocumentSummary(
doc.getId(),
doc.getTitle(),
doc.getDocumentDate(),
doc.getMetaDateEnd(),
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
senderName,
receiverName,
receivers != null ? receivers.size() : 0
);
}
JourneyItemView toView(JourneyItem item) {
DocumentSummary docSummary = null;
Document doc = item.getDocument();
if (doc != null) {
docSummary = toSummary(doc);
}
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
}
private static String buildSenderName(Document doc) {
Person sender = doc.getSender();
if (sender != null) {
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
if (!name.isBlank()) return name;
}
String senderText = doc.getSenderText();
return (senderText != null && !senderText.isBlank()) ? senderText : null;
}
private static String buildCanonicalReceiverName(Set<Person> receivers) {
if (receivers == null || receivers.isEmpty()) return null;
return receivers.stream()
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
.map(p -> {
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
return name.isBlank() ? null : name;
})
.orElse(null);
}
private static String normalizeNote(String raw) {
if (raw == null || raw.isBlank()) return null;
return raw.trim();
}
private static String sortKey(String s) {
return s != null ? s : "";
}
private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw DomainException.unauthorized("Authentication required");
}
return userService.findByEmail(auth.getName());
}
}

View File

@@ -0,0 +1,19 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
import java.util.Optional;
/**
* Input for PATCH /api/geschichten/{id}/items/{itemId}.
* Three-way semantics via Optional<String>:
* null → field absent from JSON → leave note unchanged
* Optional.empty() → {"note": null} → clear the note
* Optional.of("x") → {"note": "x"} → set the note
*
* Jackson 3.x maps JSON null to Optional.empty(); absent fields keep the Java default (null).
*/
@Data
public class JourneyItemUpdateDTO {
private Optional<String> note = null;
}

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
/**
* Read-model response for a JourneyItem. Never the JPA entity (which has a
* Geschichte back-reference that would leak / hit LazyInitializationException).
*/
public record JourneyItemView(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
DocumentSummary document,
String note
) {}

View File

@@ -0,0 +1,12 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.Data;
import java.util.List;
import java.util.UUID;
/** Input for PUT /api/geschichten/{id}/items/reorder. */
@Data
public class JourneyReorderDTO {
private List<UUID> itemIds;
}

View File

@@ -0,0 +1,73 @@
-- Production pre-requisite — run BEFORE applying this migration:
-- docker exec familienarchiv-db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-- -c "SELECT COUNT(DISTINCT (geschichte_id, document_id)) FROM geschichten_documents;"'
-- docker exec familienarchiv-db sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \
-- --table=geschichten_documents \
-- -f /tmp/pre_v72_backup_'"$(date +%Y%m%d)"'.sql'
-- Take the dump even if geschichten_documents is empty — it captures the table DEFINITION
-- for emergency reconstruction. The DROP TABLE is the only irreversible step; the
-- INSERT...SELECT is a no-op when there is no data. No DDL rollback path exists after commit.
--
-- REVERSE PROCEDURE (if V72 must be rolled back): restore the pre-V72 dump, then re-derive
-- the junction from the new table:
-- INSERT INTO geschichten_documents (geschichte_id, document_id)
-- SELECT geschichte_id, document_id FROM journey_items WHERE document_id IS NOT NULL;
-- Note: the reconstructed junction FK is ON DELETE CASCADE per the original V58
-- (NOT the new SET NULL of journey_items). Domain FKs target app_users (post-V60) —
-- do NOT hand-type V58's verbatim "REFERENCES users" DDL nor copy journey_items' SET NULL
-- into the reconstructed junction.
--
-- ASSUMPTION AS-001: The old geschichten_documents was an unordered Set — no curator order
-- existed. Ordering by meta_date is a plausible default a Lesereise lets curators
-- re-sequence. This is not a requirement; it is the best available approximation.
--
-- ASSUMPTION AS-002: Existing published Geschichten (STORYs) render the related-letters block;
-- this block visibly degrades to generic links (loss of per-document title AND date) for ALL
-- current readers during the stub window. Accepted because the reader follow-on is the
-- next-priority blocking dependency.
-- Step 1: Add type discriminator column to geschichten
ALTER TABLE geschichten
ADD COLUMN type VARCHAR(50) DEFAULT 'STORY' NOT NULL;
-- Step 2: Create journey_items table
CREATE TABLE journey_items (
id UUID NOT NULL DEFAULT gen_random_uuid(),
geschichte_id UUID NOT NULL,
position INT NOT NULL,
document_id UUID,
note TEXT,
CONSTRAINT pk_journey_items PRIMARY KEY (id),
CONSTRAINT fk_journey_items_geschichte
FOREIGN KEY (geschichte_id) REFERENCES geschichten(id) ON DELETE CASCADE,
CONSTRAINT fk_journey_items_document
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE SET NULL,
CONSTRAINT chk_journey_item_not_empty
CHECK (document_id IS NOT NULL OR note IS NOT NULL)
);
-- Step 3: Index for ordered retrieval by geschichte + position
CREATE INDEX idx_journey_items_geschichte_position
ON journey_items (geschichte_id, position ASC);
-- Step 4: Migrate geschichten_documents → journey_items
-- Positions are multiples of 1000 (headroom for drag-reorder).
-- Ordered by meta_date ASC NULLS LAST, then documents.id ASC as deterministic tiebreaker.
-- SELECT DISTINCT guards against duplicate junction rows producing duplicate journey items.
INSERT INTO journey_items (id, geschichte_id, position, document_id)
SELECT
gen_random_uuid(),
gd.geschichte_id,
(ROW_NUMBER() OVER (
PARTITION BY gd.geschichte_id
ORDER BY d.meta_date ASC NULLS LAST, d.id ASC
) * 1000)::INT AS position,
gd.document_id
FROM (
SELECT DISTINCT geschichte_id, document_id
FROM geschichten_documents
) gd
LEFT JOIN documents d ON d.id = gd.document_id;
-- Step 5: Drop the old junction table (irreversible — take the pg_dump first)
DROP TABLE geschichten_documents;

View File

@@ -0,0 +1,19 @@
-- Adds the two constraints that V72 deferred:
-- 1. UNIQUE(geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
-- Allows mid-transaction position swaps during reorder (checked at COMMIT, not per-row).
-- Requires transaction-level or session-level connection pooling (prod uses PgBouncer
-- in transaction mode — correct today; a future switch to statement-level would silently
-- break deferred checking at COMMIT).
-- 2. CHECK (position > 0) — defense against off-by-one in the append path.
--
-- MUST run in a single transaction; Flyway's default per-migration transaction satisfies this.
-- Do NOT add executeInTransaction=false or any callback that splits this migration.
ALTER TABLE journey_items
ADD CONSTRAINT uq_journey_items_geschichte_position
UNIQUE (geschichte_id, position)
DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE journey_items
ADD CONSTRAINT chk_journey_item_position
CHECK (position > 0);

View File

@@ -2,15 +2,13 @@ package org.raddatz.familienarchiv.geschichte;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.raddatz.familienarchiv.geschichte.GeschichteService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -21,22 +19,25 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import static org.hamcrest.CoreMatchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@WebMvcTest(GeschichteController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
@@ -47,11 +48,9 @@ class GeschichteControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean
GeschichteService geschichteService;
@MockitoBean
CustomUserDetailsService customUserDetailsService;
@MockitoBean GeschichteService geschichteService;
@MockitoBean JourneyItemService journeyItemService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
// ─── GET /api/geschichten ────────────────────────────────────────────────
@@ -64,8 +63,8 @@ class GeschichteControllerTest {
@Test
@WithMockUser(authorities = "READ_ALL")
void list_returns200_forReader() throws Exception {
when(geschichteService.list(any(), any(), any(), anyInt()))
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
when(geschichteService.list(any(), any(), anyInt()))
.thenReturn(List.of(summaryStub("Story A")));
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isOk())
@@ -76,13 +75,13 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
UUID personId = UUID.randomUUID();
when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
when(geschichteService.list(any(), eq(List.of(personId)), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
verify(geschichteService).list(any(), eq(List.of(personId)), anyInt());
}
@Test
@@ -90,7 +89,7 @@ class GeschichteControllerTest {
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
when(geschichteService.list(any(), eq(List.of(a, b)), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten")
@@ -98,7 +97,7 @@ class GeschichteControllerTest {
.param("personId", b.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
verify(geschichteService).list(any(), eq(List.of(a, b)), anyInt());
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@@ -107,7 +106,7 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void getById_returns200_whenFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
mockMvc.perform(get("/api/geschichten/{id}", id))
.andExpect(status().isOk())
@@ -119,7 +118,7 @@ class GeschichteControllerTest {
@WithMockUser(authorities = "READ_ALL")
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.getById(id))
when(geschichteService.getView(id))
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
mockMvc.perform(get("/api/geschichten/{id}", id))
@@ -208,8 +207,180 @@ class GeschichteControllerTest {
verify(geschichteService).delete(id);
}
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
@Test
void appendItem_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"Note\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(itemId.toString()))
.andExpect(jsonPath("$.position").value(10));
}
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenReturn(itemViewStub(itemId, 10, "Updated"));
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"Updated\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.note").value("Updated"));
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenReturn(itemViewStub(itemId, 10, null));
// Raw JSON — local objectMapper lacks JsonNullableModule
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\": null}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.note").value(nullValue()));
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void updateItemNote_returns404_whenItemNotFound() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
}
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void deleteItem_returns204_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
.andExpect(status().isNoContent());
verify(journeyItemService).delete(id, itemId);
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void deleteItem_returns404_whenItemNotFound() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
.when(journeyItemService).delete(id, itemId);
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
}
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
@Test
@WithMockUser(authorities = "READ_ALL")
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemIds\":[]}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void reorderItems_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
}
// ─── error mapping ───────────────────────────────────────────────────────
@Test
@WithMockUser(authorities = "BLOG_WRITE")
void appendItem_returns409_on_position_conflict() throws Exception {
UUID id = UUID.randomUUID();
when(journeyItemService.append(eq(id), any()))
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"note\":\"x\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
}
// ─── helpers ─────────────────────────────────────────────────────────────
private JourneyItemView itemViewStub(UUID id, int position, String note) {
return new JourneyItemView(id, position, null, note);
}
private Geschichte published(UUID id, String title) {
return Geschichte.builder()
.id(id)
@@ -220,7 +391,7 @@ class GeschichteControllerTest {
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.items(new ArrayList<>())
.build();
}
@@ -232,7 +403,27 @@ class GeschichteControllerTest {
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.items(new ArrayList<>())
.build();
}
private GeschichteView viewStub(UUID id, String title) {
return new GeschichteView(id, title, "<p>x</p>",
GeschichteStatus.PUBLISHED, GeschichteType.STORY,
null, new HashSet<>(), List.of(),
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
}
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
private GeschichteSummary summaryStub(String title) {
return new GeschichteSummary() {
public UUID getId() { return UUID.randomUUID(); }
public String getTitle() { return title; }
public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
public GeschichteType getType() { return GeschichteType.STORY; }
public AuthorSummary getAuthor() { return null; }
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
public String getBody() { return null; }
};
}
}

View File

@@ -0,0 +1,227 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.web.client.DefaultResponseErrorHandler;
import org.springframework.web.client.RestTemplate;
import software.amazon.awssdk.services.s3.S3Client;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
*
* <p>No {@code @Transactional} at class level — that would keep a session open and
* mask LazyInitializationException caused by open-in-view: false. Each test seeds data
* directly via repositories and relies on the service's own transaction boundaries.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class GeschichteHttpTest {
@LocalServerPort int port;
@MockitoBean S3Client s3Client;
@Autowired GeschichteRepository geschichteRepository;
@Autowired AppUserRepository appUserRepository;
@Autowired PasswordEncoder passwordEncoder;
private RestTemplate http;
private String baseUrl;
private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
private static final String WRITER_PASSWORD = "pass!Geschichte1";
@BeforeEach
void setUp() {
http = noThrowRestTemplate();
baseUrl = "http://localhost:" + port;
geschichteRepository.deleteAll();
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
appUserRepository.save(AppUser.builder()
.email(WRITER_EMAIL)
.password(passwordEncoder.encode(WRITER_PASSWORD))
.build());
}
// ─── GET /api/geschichten ────────────────────────────────────────────────
@Test
void list_returns_200_and_empty_array_when_no_stories_exist() {
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten", HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).isEqualTo("[]");
}
@Test
void list_returns_200_and_does_not_500_when_stories_have_journey_items() {
// Seed a JOURNEY directly — items are LAZY; without @Transactional(readOnly=true) +
// Hibernate.initialize in getById() this would 500. list() uses a projection so it
// must also never touch items.
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
Geschichte journey = Geschichte.builder()
.title("Reise durch die Briefe")
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
JourneyItem item = JourneyItem.builder()
.geschichte(journey)
.position(1000)
.note("Einleitung")
.build();
journey.getItems().add(item);
geschichteRepository.save(journey);
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten", HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("Reise durch die Briefe");
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@Test
void getById_returns_200_with_items_and_does_not_500_open_in_view_false() {
// This test is the canonical guard against LazyInitializationException.
// open-in-view: false means the Hibernate session is closed when Jackson serializes.
// GeschichteService.getById() must initialize items inside its @Transactional boundary.
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
Geschichte journey = Geschichte.builder()
.title("Familiengeschichte")
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
JourneyItem note = JourneyItem.builder()
.geschichte(journey).position(1000).note("Prolog").build();
JourneyItem note2 = JourneyItem.builder()
.geschichte(journey).position(2000).note("Epilog").build();
journey.getItems().add(note);
journey.getItems().add(note2);
Geschichte saved = geschichteRepository.save(journey);
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("Familiengeschichte")
.contains("Prolog")
.contains("Epilog");
}
@Test
void getById_returns_404_for_unknown_id() {
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + UUID.randomUUID(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(404);
assertThat(response.getBody()).contains("GESCHICHTE_NOT_FOUND");
}
@Test
void getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() {
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
Geschichte draft = Geschichte.builder()
.title("Geheimer Entwurf")
.status(GeschichteStatus.DRAFT)
.author(writer)
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
Geschichte saved = geschichteRepository.save(draft);
// Writer lacks explicit BLOG_WRITE permission in the app_users table,
// so from the service's perspective they're a reader.
String session = loginAsWriter();
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
new HttpEntity<>(sessionHeaders(session)), String.class);
assertThat(response.getStatusCode().value()).isEqualTo(404);
}
// ─── helpers ─────────────────────────────────────────────────────────────
private String loginAsWriter() {
String xsrf = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
headers.set("X-XSRF-TOKEN", xsrf);
String body = "{\"email\":\"" + WRITER_EMAIL + "\",\"password\":\"" + WRITER_PASSWORD + "\"}";
ResponseEntity<String> resp = http.postForEntity(
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
return extractFaSessionCookie(resp);
}
private HttpHeaders sessionHeaders(String sessionId) {
HttpHeaders headers = new HttpHeaders();
headers.set("Cookie", "fa_session=" + sessionId);
return headers;
}
private String extractFaSessionCookie(ResponseEntity<?> response) {
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
if (setCookieHeader == null) return "";
return setCookieHeader.stream()
.filter(c -> c.startsWith("fa_session="))
.map(c -> c.split(";")[0].substring("fa_session=".length()))
.findFirst()
.orElse("");
}
private RestTemplate noThrowRestTemplate() {
RestTemplate template = new RestTemplate();
template.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return false;
}
});
return template;
}
}

View File

@@ -0,0 +1,196 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class GeschichteListProjectionTest {
@Autowired GeschichteRepository geschichteRepository;
@Autowired AppUserRepository appUserRepository;
@Autowired PersonRepository personRepository;
AppUser author;
AppUser otherAuthor;
@BeforeEach
void setUp() {
geschichteRepository.deleteAll();
author = appUserRepository.save(AppUser.builder()
.email("author@test").password("pw").build());
otherAuthor = appUserRepository.save(AppUser.builder()
.email("other@test").password("pw").build());
}
// ─── findSummaries returns only the requested status ─────────────────────
@Test
void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() {
geschichteRepository.save(published("Veröffentlicht", author));
geschichteRepository.save(draft("Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
}
@Test
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
geschichteRepository.save(draft("Nur Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
assertThat(result).isEmpty();
}
// ─── AuthorSummary nested projection ─────────────────────────────────────
@Test
void findSummaries_exposes_nested_author_firstName_lastName_email() {
AppUser richAuthor = appUserRepository.save(AppUser.builder()
.email("franz@raddatz.de").password("pw").build());
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
assertThat(result).hasSize(1);
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
assertThat(a.getEmail()).isEqualTo("franz@raddatz.de");
}
// ─── GeschichteType is exposed ────────────────────────────────────────────
@Test
void findSummaries_exposes_type_field() {
Geschichte journey = Geschichte.builder()
.title("Eine Reise")
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(author)
.publishedAt(LocalDateTime.now())
.build();
geschichteRepository.save(journey);
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
assertThat(result).hasSize(1);
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
}
// ─── authorId filter (own-drafts gate) ───────────────────────────────────
@Test
void findSummaries_with_authorId_returns_only_own_drafts() {
geschichteRepository.save(draft("Mein Entwurf", author));
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
}
// ─── personCount = 0 → no person filter ──────────────────────────────────
@Test
void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() {
geschichteRepository.save(published("A", author));
geschichteRepository.save(published("B", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
assertThat(result).hasSize(2);
}
// ─── personCount > 0 AND-semantics ───────────────────────────────────────
@Test
void findSummaries_with_one_personId_returns_only_linked_stories() {
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
Geschichte withFranz = published("Franz story", author);
withFranz.getPersons().add(franz);
geschichteRepository.save(withFranz);
Geschichte withAnna = published("Anna story", author);
withAnna.getPersons().add(anna);
geschichteRepository.save(withAnna);
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
}
@Test
void findSummaries_with_two_personIds_uses_AND_semantics() {
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
Geschichte both = published("Both", author);
both.getPersons().add(franz);
both.getPersons().add(anna);
geschichteRepository.save(both);
Geschichte onlyFranz = published("Only Franz", author);
onlyFranz.getPersons().add(franz);
geschichteRepository.save(onlyFranz);
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Both");
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte published(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.PUBLISHED)
.author(writer)
.publishedAt(LocalDateTime.now())
.build();
}
private Geschichte draft(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.DRAFT)
.author(writer)
.build();
}
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
private List<UUID> sentinel() {
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
}
}

View File

@@ -0,0 +1,38 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GeschichteQueryServiceTest {
@Mock
GeschichteRepository geschichteRepository;
@InjectMocks
GeschichteQueryService geschichteQueryService;
@Test
void existsById_returns_true_when_geschichte_exists() {
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(true);
assertThat(geschichteQueryService.existsById(id)).isTrue();
}
@Test
void existsById_returns_false_when_geschichte_does_not_exist() {
UUID id = UUID.randomUUID();
when(geschichteRepository.existsById(id)).thenReturn(false);
assertThat(geschichteQueryService.existsById(id)).isFalse();
}
}

View File

@@ -8,9 +8,12 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.geschichte.GeschichteView;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.springframework.beans.factory.annotation.Autowired;
@@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest {
S3Client s3Client;
@Autowired GeschichteService geschichteService;
@Autowired JourneyItemService journeyItemService;
@Autowired GeschichteRepository geschichteRepository;
@Autowired PersonRepository personRepository;
@Autowired AppUserRepository appUserRepository;
@@ -86,7 +90,7 @@ class GeschichteServiceIntegrationTest {
// Reader cannot see DRAFT in list
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
assertThat(geschichteService.list(null, List.of(), 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.getId();
@@ -102,11 +106,12 @@ class GeschichteServiceIntegrationTest {
// Reader can now see and fetch it
authenticateAs(reader, Permission.READ_ALL);
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1);
assertThat(geschichteService.list(null, List.of(franz.getId()), 50)).hasSize(1);
Geschichte fetched = geschichteService.getById(draftId);
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
// Delete as writer; join rows go with it
authenticateAs(writer, Permission.BLOG_WRITE);
@@ -136,26 +141,26 @@ class GeschichteServiceIntegrationTest {
authenticateAs(reader, Permission.READ_ALL);
// No filter → all three
assertThat(geschichteService.list(null, List.of(), null, 50))
.extracting(Geschichte::getId)
assertThat(geschichteService.list(null, List.of(), 50))
.extracting(GeschichteSummary::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// Single filter (Anna) → all three
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
.extracting(Geschichte::getId)
assertThat(geschichteService.list(null, List.of(a.getId()), 50))
.extracting(GeschichteSummary::getId)
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
.extracting(Geschichte::getId)
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), 50))
.extracting(GeschichteSummary::getId)
.containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both)
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50))
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), 50))
.isEmpty();
// AND: Anna AND Bertha AND Carl → none
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50))
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), 50))
.isEmpty();
}
@@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest {
geschichteService.create(dto);
authenticateAs(writer2, Permission.BLOG_WRITE);
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), 50);
assertThat(result).isEmpty();
}

View File

@@ -7,26 +7,22 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
@@ -37,7 +33,9 @@ import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -45,17 +43,13 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GeschichteServiceTest {
@Mock
GeschichteRepository geschichteRepository;
@Mock
PersonService personService;
@Mock
DocumentService documentService;
@Mock
UserService userService;
@Mock GeschichteRepository geschichteRepository;
@Mock PersonService personService;
@Mock DocumentService documentService;
@Mock UserService userService;
@Mock JourneyItemService journeyItemService;
@InjectMocks
GeschichteService geschichteService;
@InjectMocks GeschichteService geschichteService;
AppUser writer;
AppUser reader;
@@ -96,7 +90,8 @@ class GeschichteServiceTest {
Geschichte result = geschichteService.getById(id);
assertThat(result).isSameAs(draft);
assertThat(result.getId()).isEqualTo(id);
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
}
@Test
@@ -108,7 +103,8 @@ class GeschichteServiceTest {
Geschichte result = geschichteService.getById(id);
assertThat(result).isSameAs(published);
assertThat(result.getId()).isEqualTo(id);
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
}
@Test
@@ -123,79 +119,163 @@ class GeschichteServiceTest {
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
// ─── getView ──────────────────────────────────────────────────────────────
@Test
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
Geschichte published = published(id);
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
GeschichteView view = geschichteService.getView(id);
assertThat(view.id()).isEqualTo(id);
assertThat(view.items()).containsExactly(item);
verify(journeyItemService).getItems(id);
}
@Test
void getView_throws_NOT_FOUND_when_id_unknown() {
authenticateAs(reader, Permission.READ_ALL);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
assertThatThrownBy(() -> geschichteService.getView(id))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
}
@Test
void toView_author_displayName_uses_firstName_lastName() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("author@test")
.firstName("Hans").lastName("Raddatz").build());
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
}
@Test
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("anon@test").build());
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
}
@Test
void toView_author_email_is_not_in_author_view() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
published.setAuthor(AppUser.builder()
.id(UUID.randomUUID()).email("secret@test")
.firstName("Max").lastName("M").build());
GeschichteView result = geschichteService.toView(published, List.of());
// AuthorView exposes only id + displayName — no email field at all
assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
assertThat(result.author().displayName()).doesNotContain("secret@test");
}
@Test
void toView_persons_are_mapped_to_PersonView() {
UUID id = UUID.randomUUID();
UUID personId = UUID.randomUUID();
Geschichte published = published(id);
published.setPersons(new HashSet<>(List.of(
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
)));
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.persons()).hasSize(1);
GeschichteView.PersonView pv = result.persons().iterator().next();
assertThat(pv.id()).isEqualTo(personId);
assertThat(pv.firstName()).isEqualTo("Franz");
assertThat(pv.lastName()).isEqualTo("Raddatz");
}
@Test
void toView_items_are_passed_through() {
UUID id = UUID.randomUUID();
Geschichte published = published(id);
GeschichteView result = geschichteService.toView(published, List.of());
assertThat(result.items()).isEmpty();
}
// ─── list ─────────────────────────────────────────────────────────────────
@Test
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
.thenReturn(List.of());
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
geschichteService.list(null, List.of(), 50);
// Status pinning lives inside the Specification; we assert end-to-end behaviour
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
// through the spec-aware repository method.
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
}
@Test
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
GeschichteSummary s1 = mock(GeschichteSummary.class);
GeschichteSummary s2 = mock(GeschichteSummary.class);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
.thenReturn(List.of(s1, s2));
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
List<GeschichteSummary> out = geschichteService.list(null, List.of(), 50);
assertThat(out).hasSize(2);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
}
@Test
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
authenticateAs(reader, Permission.READ_ALL);
UUID personId = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
.thenReturn(List.of());
geschichteService.list(null, List.of(personId), null, 50);
geschichteService.list(null, List.of(personId), 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
}
@Test
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
authenticateAs(reader, Permission.READ_ALL);
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
.thenReturn(List.of());
geschichteService.list(null, List.of(a, b), null, 50);
geschichteService.list(null, List.of(a, b), 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
}
@Test
void list_filters_by_documentId() {
void list_caps_limit_at_max_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
UUID documentId = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of());
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
.thenReturn(List.of(mock(GeschichteSummary.class)));
geschichteService.list(null, List.of(), documentId, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
}
@Test
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
.thenReturn(List.of(published(UUID.randomUUID())));
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
List<GeschichteSummary> out = geschichteService.list(null, List.of(), 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
@@ -282,25 +362,6 @@ class GeschichteServiceTest {
assertThat(saved.getPersons()).containsExactly(person);
}
@Test
void create_resolves_documentIds_via_DocumentService() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
UUID docId = UUID.randomUUID();
Document doc = Document.builder().id(docId).build();
when(documentService.getDocumentById(docId)).thenReturn(doc);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Linked doc");
dto.setDocumentIds(List.of(docId));
Geschichte saved = geschichteService.create(dto);
assertThat(saved.getDocuments()).containsExactly(doc);
}
@Test
void create_throws_BAD_REQUEST_when_title_blank() {
authenticateAs(writer, Permission.BLOG_WRITE);
@@ -426,7 +487,7 @@ class GeschichteServiceTest {
.body("<p>body</p>")
.status(GeschichteStatus.DRAFT)
.persons(new HashSet<>())
.documents(new HashSet<>())
.items(new ArrayList<>())
.build();
}
@@ -438,7 +499,7 @@ class GeschichteServiceTest {
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now().minusHours(1))
.persons(new HashSet<>())
.documents(new HashSet<>())
.items(new ArrayList<>())
.build();
}
}

View File

@@ -0,0 +1,99 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
* rollback-only and cascades into TransactionSystemException on teardown.
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JourneyItemConstraintsTest {
@MockitoBean
S3Client s3Client;
@Autowired JdbcTemplate jdbcTemplate;
@Autowired GeschichteRepository geschichteRepository;
@Autowired DocumentRepository documentRepository;
private UUID geschichteId;
private UUID documentId;
@BeforeEach
void seed() {
jdbcTemplate.execute("DELETE FROM journey_items");
Document doc = documentRepository.save(Document.builder()
.title("Constraints-Test-Doc")
.originalFilename("ct.pdf")
.status(DocumentStatus.UPLOADED)
.build());
documentId = doc.getId();
Geschichte g = geschichteRepository.save(Geschichte.builder()
.title("Constraints-Test-Journey")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
geschichteId = g.getId();
}
@Test
void unique_constraint_is_deferrable_initially_deferred() {
Boolean condeferrable = jdbcTemplate.queryForObject(
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
Boolean.class);
Boolean condeferred = jdbcTemplate.queryForObject(
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
Boolean.class);
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
}
@Test
void position_check_rejects_nonpositive() {
UUID itemId = UUID.randomUUID();
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
itemId, geschichteId, 0, "test"))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void unique_constraint_rejects_duplicate_position_per_geschichte() {
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId))
.isInstanceOf(DataIntegrityViolationException.class);
}
}

View File

@@ -0,0 +1,294 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.AppUserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class JourneyItemIntegrationTest {
@MockitoBean
S3Client s3Client;
@PersistenceContext
EntityManager em;
@Autowired GeschichteRepository geschichteRepository;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired JourneyItemService journeyItemService;
@Autowired DocumentRepository documentRepository;
@Autowired AppUserRepository appUserRepository;
Geschichte journey;
Document doc;
AppUser writer;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("journey-writer@test")
.password("hash")
.build());
doc = documentRepository.save(Document.builder()
.title("Testbrief")
.originalFilename("testbrief.pdf")
.status(DocumentStatus.UPLOADED)
.build());
journey = geschichteRepository.save(Geschichte.builder()
.title("Eine Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
em.flush();
em.clear();
}
@AfterEach
void clearSecurity() {
SecurityContextHolder.clearContext();
}
private void authenticateAs(AppUser user, Permission... permissions) {
var authorities = java.util.Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.name()))
.toList();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
}
// ─── @OrderBy ─────────────────────────────────────────────────────────────
@Test
void items_are_returned_in_position_order_regardless_of_insertion_order() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).document(doc).build();
managed.getItems().addAll(List.of(third, first, second));
geschichteRepository.save(managed);
em.flush();
em.clear();
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
List<Integer> positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();
assertThat(positions).containsExactly(1000, 2000, 3000);
}
// ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────
@Test
void deleting_geschichte_cascade_deletes_all_journey_items() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
geschichteRepository.save(managed);
em.flush();
em.clear();
UUID geschichteId = journey.getId();
geschichteRepository.deleteById(geschichteId);
em.flush();
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
}
@Test
void removing_item_from_items_list_triggers_orphan_removal() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
managed.getItems().add(item);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
geschichteRepository.save(reloaded);
em.flush();
assertThat(journeyItemRepository.findById(itemId)).isEmpty();
}
// ─── GeschichteType round-trip ────────────────────────────────────────────
@Test
void type_persists_as_JOURNEY_and_roundtrips() {
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
}
@Test
void type_defaults_to_STORY_for_new_geschichten() {
Geschichte story = geschichteRepository.save(Geschichte.builder()
.title("Erinnerung")
.status(GeschichteStatus.DRAFT)
.build());
em.flush();
em.clear();
Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
}
// ─── Note-only item (document_id IS NULL) ─────────────────────────────────
@Test
void note_only_item_persists_without_document() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem note = JourneyItem.builder()
.geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
managed.getItems().add(note);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID noteId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
assertThat(reloaded.getDocumentId()).isNull();
assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
}
// ─── Document-backed item exposes documentId ──────────────────────────────
@Test
void document_backed_item_exposes_document_uuid_via_getDocumentId() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem item = JourneyItem.builder()
.geschichte(managed).position(1000).document(doc).build();
managed.getItems().add(item);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
}
// ─── ON DELETE SET NULL ───────────────────────────────────────────────────
@Test
void deleting_document_sets_item_document_to_null_not_delete_item() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem item = JourneyItem.builder()
.geschichte(managed).position(1000).document(doc).note("still here").build();
managed.getItems().add(item);
Geschichte saved = geschichteRepository.save(managed);
em.flush();
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
em.clear();
// Delete document — ON DELETE SET NULL fires at DB level
documentRepository.deleteById(doc.getId());
em.flush();
em.clear();
JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("still here");
}
// ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
@Test
void saving_item_with_neither_document_nor_note_violates_check_constraint() {
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
JourneyItem empty = JourneyItem.builder()
.geschichte(managed).position(1000).build();
assertThatThrownBy(() -> {
journeyItemRepository.save(empty);
journeyItemRepository.flush();
}).isInstanceOf(Exception.class);
}
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
@Test
void append_persists_item_at_position_10() {
// Arrange: authenticate as a user with BLOG_WRITE
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("First stop");
// Act
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
em.flush();
em.clear();
// Assert: item exists in DB at position 10
assertThat(view.position()).isEqualTo(10);
assertThat(view.note()).isEqualTo("First stop");
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
assertThat(persisted).hasSize(1);
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
}
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
@Test
void reorder_swaps_positions_atomically() {
// Arrange: append two items (pos 10, pos 20)
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
dto1.setNote("Item one");
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
dto2.setNote("Item two");
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
assertThat(item1View.position()).isEqualTo(10);
assertThat(item2View.position()).isEqualTo(20);
// Act: reorder with [item2, item1]
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
em.flush();
em.clear();
// Assert: item2 is now at position 10, item1 is at position 20
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
assertThat(persisted).hasSize(2);
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
}
}

View File

@@ -0,0 +1,689 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.geschichte.GeschichteType;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.user.UserService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class JourneyItemServiceTest {
@Mock JourneyItemRepository journeyItemRepository;
@Mock GeschichteQueryService geschichteQueryService;
@Mock DocumentService documentService;
@Mock AuditService auditService;
@Mock UserService userService;
@InjectMocks JourneyItemService journeyItemService;
UUID geschichteId = UUID.randomUUID();
UUID itemId = UUID.randomUUID();
UUID docId = UUID.randomUUID();
UUID actorId = UUID.randomUUID();
@BeforeEach
void setupAuth() {
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken("test@test.de", null,
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
}
// ─── toSummary — name composition ────────────────────────────────────────
@Test
void toSummary_uses_linked_person_firstName_lastName() {
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
Document doc = makeDoc(docId, sender, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
}
@Test
void toSummary_falls_back_to_senderText_when_no_person() {
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isEqualTo("Familie Müller");
}
@Test
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
Document doc = makeDoc(docId, null, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.senderName()).isNull();
}
@Test
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
Document doc = makeDoc(docId, null, List.of(), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.receiverCount()).isEqualTo(0);
assertThat(summary.receiverName()).isNull();
}
@Test
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.receiverCount()).isEqualTo(2);
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
}
@Test
void toSummary_datePrecision_SEASON_roundtrips() {
Document doc = makeDoc(docId, null, List.of(), null, null);
doc.setMetaDatePrecision(DatePrecision.SEASON);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
}
@Test
void toSummary_datePrecision_APPROX_roundtrips() {
Document doc = makeDoc(docId, null, List.of(), null, null);
doc.setMetaDatePrecision(DatePrecision.APPROX);
var summary = journeyItemService.toSummary(doc);
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
}
// ─── append ──────────────────────────────────────────────────────────────
@Test
void append_to_empty_journey_starts_at_10() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.save(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(10);
}
@Test
void append_after_reorder_continues_from_max_position() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
when(journeyItemRepository.save(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(50);
}
@Test
void append_returns400_when_neither_documentId_nor_note() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.hasMessageContaining("documentId or note");
}
@Test
void append_returns400_when_note_trims_to_empty_and_no_document() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote(" \n ");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void append_returns400_when_note_exceeds_5000_chars() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(5001));
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void append_returns409_on_non_JOURNEY_type() {
Geschichte story = Geschichte.builder()
.id(geschichteId)
.title("Story")
.type(GeschichteType.STORY)
.status(GeschichteStatus.DRAFT)
.build();
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_MISMATCH));
}
@Test
void append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY() {
// Arrange: mock geschichteQueryService.findById() to return a STORY-type Geschichte
UUID storyId = UUID.randomUUID();
Geschichte story = Geschichte.builder()
.id(storyId)
.type(GeschichteType.STORY)
.build();
when(geschichteQueryService.findById(storyId)).thenReturn(Optional.of(story));
// Act + Assert: calling append throws GESCHICHTE_TYPE_MISMATCH
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(storyId, dto))
.isInstanceOf(DomainException.class);
// Verify: document service was never touched — type guard fired first
verifyNoInteractions(documentService);
}
@Test
void append_returns404_when_documentId_does_not_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(documentService.findSummaryByIdInternal(docId))
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
}
@Test
void append_returns409_when_100_items_exist() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
}
@Test
void cap_is_COUNT_based_not_MAX_position_based() {
// 99 rows with MAX(position)=2000 should still accept the 100th append
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
when(journeyItemRepository.save(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
}
@Test
void append_audits_JOURNEY_ITEM_ADDED() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.save(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
journeyItemService.append(geschichteId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
}
// ─── updateNote ───────────────────────────────────────────────────────────
@Test
void updateNote_absent_leaves_note_unchanged() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
// note is null by default — absent from JSON, no-op
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isEqualTo("Original note");
verify(journeyItemRepository, never()).save(any());
}
@Test
void updateNote_null_clears_note_when_document_is_present() {
Geschichte journey = journey(geschichteId);
Document doc = makeDoc(docId, null, List.of(), null, null);
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.empty());
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isNull();
}
@Test
void updateNote_string_sets_note() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, null);
item.setNote(null);
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("New note"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isEqualTo("New note");
}
@Test
void updateNote_null_returns400_when_item_has_no_document() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.empty());
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void updateNote_whitespace_only_including_newlines_stored_as_null() {
Geschichte journey = journey(geschichteId);
Document doc = makeDoc(docId, null, List.of(), null, null);
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("\n \n"));
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
assertThat(view.note()).isNull();
}
@Test
void patch_returns400_when_note_exceeds_5000_chars() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("x".repeat(5001)));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void updateNote_auditsNoteUpdate() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, null);
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
when(journeyItemRepository.save(item)).thenReturn(saved);
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("New note"));
journeyItemService.updateNote(geschichteId, itemId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
}
@Test
void patch_returns404_when_item_belongs_to_different_journey() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
dto.setNote(Optional.of("text"));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
}
// ─── delete ───────────────────────────────────────────────────────────────
@Test
void delete_returns404_when_item_already_deleted() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
}
@Test
void delete_no_audit_when_item_not_found() {
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
.isInstanceOf(DomainException.class);
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
}
@Test
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
Geschichte journey = journey(geschichteId);
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
journeyItemService.delete(geschichteId, itemId);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
}
// ─── reorder ─────────────────────────────────────────────────────────────
@Test
void reorder_unknownGeschichteId_throws404() {
UUID unknownId = UUID.randomUUID();
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
}
@Test
void reorder_returns400_when_itemIds_contain_duplicates() {
UUID id1 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id1)); // duplicate
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void reorder_returns400_when_itemId_belongs_to_different_journey() {
UUID foreignId = UUID.randomUUID();
UUID localId = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(foreignId));
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR));
}
@Test
void reorder_returns400_when_ids_have_extra_items() {
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id2));
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void reorder_returns200_when_empty_on_empty_journey() {
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
assertThat(result).isEmpty();
}
@Test
void reorder_returns400_when_empty_on_nonempty_journey() {
UUID id1 = UUID.randomUUID();
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of());
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
.isInstanceOf(DomainException.class);
}
@Test
void reorder_returns_items_in_new_order_starting_at_10() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
UUID id2 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1, id2)); // want id1 first
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(2);
assertThat(views.get(0).id()).isEqualTo(id1);
assertThat(views.get(0).position()).isEqualTo(10);
assertThat(views.get(1).id()).isEqualTo(id2);
assertThat(views.get(1).position()).isEqualTo(20);
}
@Test
void reorder_identical_order_returns200() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1));
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(1);
assertThat(views.get(0).position()).isEqualTo(10);
}
@Test
void reorder_of_grandfathered_over_cap_journey_succeeds() {
Geschichte journey = journey(geschichteId);
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
List<UUID> ids = new java.util.ArrayList<>();
List<JourneyItem> items = new java.util.ArrayList<>();
for (int i = 1; i <= 130; i++) {
UUID id = UUID.randomUUID();
ids.add(id);
items.add(savedItem(id, journey, i * 10, null, "item " + i));
}
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(ids);
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
assertThat(views).hasSize(130);
}
@Test
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
Geschichte journey = journey(geschichteId);
UUID id1 = UUID.randomUUID();
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
JourneyReorderDTO dto = new JourneyReorderDTO();
dto.setItemIds(List.of(id1));
journeyItemService.reorder(geschichteId, dto);
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
}
// ─── helpers ─────────────────────────────────────────────────────────────
private Geschichte journey(UUID id) {
return Geschichte.builder()
.id(id)
.title("Test Journey")
.type(GeschichteType.JOURNEY)
.status(GeschichteStatus.DRAFT)
.build();
}
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
return JourneyItem.builder()
.id(id)
.geschichte(g)
.position(position)
.document(null) // no document entity to avoid LAZY issues in unit tests
.note(note)
.build();
}
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
JourneyItem item = JourneyItem.builder()
.id(id)
.geschichte(g)
.position(position)
.document(doc)
.note(note)
.build();
return item;
}
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
Document doc = Document.builder()
.id(id)
.title("Test Doc")
.originalFilename("test.pdf")
.status(DocumentStatus.UPLOADED)
.senderText(senderText)
.receiverText(receiverText)
.sender(sender)
.build();
doc.setReceivers(new HashSet<>(receivers));
return doc;
}
}

View File

@@ -38,7 +38,7 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body).
**`geschichte`** — family stories and Lesereisen. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle) and `JourneyItem` (ordered stops in a JOURNEY-type Geschichte). Two subtypes: `STORY` (prose) and `JOURNEY` (curated document sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL).
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).

View File

@@ -149,7 +149,17 @@ _See also [Chronik](#chronik-internal)._
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a single stop in a *Lesereise* (`Geschichte` with `type=JOURNEY`). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per journey). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`).
**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List<JourneyItemView>` loaded via a separate query rather than a lazy collection.
**JourneyItemView** (`JourneyItemView`) `[internal]` — lean view record for a single `JourneyItem` surface, containing `id`, `position`, an optional `DocumentSummary`, and an optional `note`.
**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info.
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.

View File

@@ -0,0 +1,43 @@
# ADR-035 — `Optional<String>` for three-way PATCH semantics
**Status:** Accepted
**Date:** 2026-06-08
**Issue:** #751 (JourneyItem CRUD API)
## Context
The `PATCH /api/geschichten/{id}/items/{itemId}` endpoint must distinguish three cases for the `note` field:
| JSON body | Intended meaning |
|-------------------|-----------------------|
| `{"note": "text"}`| Set note to "text" |
| `{"note": null}` | Clear the note |
| `{}` (absent) | Leave note unchanged |
The standard library for this on Jackson 2.x is `jackson-databind-nullable` (`JsonNullable<T>` from `org.openapitools`). However, that library targets `com.fasterxml.jackson.*` (Jackson 2.x) and is incompatible with Spring Boot 4.0 / Spring Framework 7, which uses `tools.jackson.*` (Jackson 3.x). The module fails to register and throws at startup.
## Decision
Use `Optional<String>` with Java's default field initializer (`= null`) to encode the three states:
```java
@Data
public class JourneyItemUpdateDTO {
private Optional<String> note = null; // Java default — absent = no-op
}
```
| Java value | JSON wire | Semantics |
|--------------------|-------------------|---------------|
| `null` (default) | field absent | no-op |
| `Optional.empty()` | `{"note": null}` | clear |
| `Optional.of("x")` | `{"note": "x"}` | set |
Jackson 3.x natively maps a JSON `null` to `Optional.empty()` and leaves absent fields at their Java default. No custom module is needed.
## Consequences
- No external dependency for PATCH semantics — simpler pom.xml.
- The DTO field type is `Optional<String>`, not `String` — service code must null-check the field first (`if (noteField == null) return;`) and then call `.orElse(null)` to unwrap.
- This pattern applies to any future PATCH DTO that needs three-way semantics on a nullable field.
- `jackson-databind-nullable` is removed from `pom.xml`; `JacksonConfig.java` is kept as a placeholder for future custom modules.

View File

@@ -16,8 +16,10 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories (STORY) and reading journeys (JOURNEY). Returns GeschichteSummary projections for list; full Geschichte with JourneyItems for detail. Requires BLOG_WRITE permission for write operations.")
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Supports two subtypes: STORY (prose) and JOURNEY (ordered JourneyItem sequence). Sanitizes HTML body with an allowlist policy.")
Component(geschQuerySvc, "GeschichteQueryService", "Spring Service", "Read-only facade over GeschichteRepository. Exposes existsById() and findById() to prevent JourneyItemService from crossing domain boundaries.")
Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap). Enforces JOURNEY-type guard on append.")
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
}
@@ -38,6 +40,10 @@ Rel(notifCtrl, notifSvc, "Delegates to")
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
Rel(geschCtrl, geschSvc, "Delegates to")
Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD")
Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type")
Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")
Rel(auditSvc, db, "Writes audit_log", "JDBC")
Rel(auditQuery, db, "Reads audit_log", "JDBC")
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")

View File

@@ -11,8 +11,8 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (rich text editor, person linking, POST /api/geschichten) or JOURNEY placeholder (editor deferred to #753). Edit: PUT /api/geschichten/{id}. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
@@ -24,8 +24,8 @@ Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearc
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}", "HTTP / JSON")
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")

View File

@@ -1,6 +1,6 @@
@startuml db-orm
' Schema source: Flyway V1V69 (excl. V37, V43 — intentionally removed)
' Schema as of: V69 (2026-05-27)
' Schema source: Flyway V1V72 (excl. V37, V43 — intentionally removed)
' Schema as of: V72 (2026-06-08)
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
hide circle
@@ -359,6 +359,7 @@ package "Supporting" {
title : VARCHAR(255) NOT NULL
body : TEXT
status : VARCHAR(32) NOT NULL
type : VARCHAR(32) NOT NULL
author_id : UUID <<FK>>
created_at : TIMESTAMP NOT NULL
updated_at : TIMESTAMP NOT NULL
@@ -370,9 +371,15 @@ package "Supporting" {
person_id : UUID <<FK>>
}
entity geschichten_documents {
entity journey_items {
id : UUID <<PK>>
--
geschichte_id : UUID <<FK>>
document_id : UUID <<FK>>
position : INTEGER NOT NULL CHECK (position > 0)
note : TEXT
==
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
}
}
@@ -436,7 +443,7 @@ audit_log }o--o| documents : document_id
geschichten }o--o| app_users : author_id
geschichten_persons }o--|| geschichten : geschichte_id
geschichten_persons }o--|| persons : person_id
geschichten_documents }o--|| geschichten : geschichte_id
geschichten_documents }o--|| documents : document_id
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
@enduml

View File

@@ -66,7 +66,7 @@ package "Supporting" {
entity audit_log
entity geschichten
entity geschichten_persons
entity geschichten_documents
entity journey_items
}
' Auth relationships
@@ -129,7 +129,7 @@ audit_log }o--o| documents : document_id
geschichten }o--o| app_users : author_id
geschichten_persons }o--|| geschichten : geschichte_id
geschichten_persons }o--|| persons : person_id
geschichten_documents }o--|| geschichten : geschichte_id
geschichten_documents }o--|| documents : document_id
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
@enduml

View File

@@ -1023,6 +1023,11 @@
"nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert bitte laden Sie die Seite neu.",
"error_journey_at_capacity": "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht.",
"error_geschichte_type_mismatch": "Diese Geschichte ist keine Lesereise Reise-Einträge sind hier nicht erlaubt.",
"journey_item_document_deleted": "[Dokument gelöscht]",
"geschichten_index_title": "Geschichten",
"geschichten_new_button": "Neue Geschichte",
"geschichten_filter_all_pill": "Alle",
@@ -1037,6 +1042,7 @@
"geschichten_published_on": "veröffentlicht am {date}",
"geschichten_persons_section": "Personen in dieser Geschichte",
"geschichten_documents_section": "Erwähnte Dokumente",
"geschichten_document_link_placeholder": "Dokument öffnen",
"geschichten_card_heading": "Geschichten",
"geschichten_card_write_action": "+ Geschichte schreiben",
"geschichten_card_attach_action": "+ Geschichte anhängen",
@@ -1153,5 +1159,20 @@
"themen_alle": "Alle Themen",
"themen_leer": "Noch keine Themen vergeben.",
"themen_weitere": "+ {count} weitere",
"themen_dokumente": "{count} Dokumente"
"themen_dokumente": "{count} Dokumente",
"journey_badge_list": "REISE",
"journey_badge_detail": "LESEREISE",
"journey_selector_question": "Was möchtest du erstellen?",
"journey_selector_story_title": "Geschichte",
"journey_selector_story_desc": "Eine erzählte Geschichte mit Bildern und Text.",
"journey_selector_journey_title": "Lesereise",
"journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.",
"journey_selector_next_btn": "Weiter",
"journey_placeholder_back": "andere Auswahl",
"journey_placeholder_heading": "Lesereise-Editor folgt in #753",
"journey_item_open_aria": "Brief vom {date} öffnen",
"journey_item_open_aria_undated": "Brief öffnen",
"journey_empty_state": "Diese Lesereise ist noch leer.",
"journey_interlude_aria_label": "Kuratorennotiz",
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren."
}

View File

@@ -1023,6 +1023,11 @@
"nav_stammbaum": "Family tree",
"nav_geschichten": "Stories",
"error_geschichte_not_found": "The story was not found.",
"error_journey_item_not_found": "The journey item was not found.",
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
"error_journey_at_capacity": "The reading journey has already reached the maximum of 100 items.",
"error_geschichte_type_mismatch": "This story is not a reading journey — journey items are not allowed here.",
"journey_item_document_deleted": "[Document deleted]",
"geschichten_index_title": "Stories",
"geschichten_new_button": "New story",
"geschichten_filter_all_pill": "All",
@@ -1037,6 +1042,7 @@
"geschichten_published_on": "published on {date}",
"geschichten_persons_section": "People in this story",
"geschichten_documents_section": "Referenced documents",
"geschichten_document_link_placeholder": "Open document",
"geschichten_card_heading": "Stories",
"geschichten_card_write_action": "+ Write a story",
"geschichten_card_attach_action": "+ Attach a story",
@@ -1153,5 +1159,20 @@
"themen_alle": "All Topics",
"themen_leer": "No topics assigned yet.",
"themen_weitere": "+ {count} more",
"themen_dokumente": "{count} documents"
"themen_dokumente": "{count} documents",
"journey_badge_list": "JOURNEY",
"journey_badge_detail": "READING JOURNEY",
"journey_selector_question": "What would you like to create?",
"journey_selector_story_title": "Story",
"journey_selector_story_desc": "A narrative story with images and text.",
"journey_selector_journey_title": "Reading Journey",
"journey_selector_journey_desc": "A curated selection of letters with notes.",
"journey_selector_next_btn": "Continue",
"journey_placeholder_back": "different selection",
"journey_placeholder_heading": "Reading Journey editor coming in #753",
"journey_item_open_aria": "Open letter from {date}",
"journey_item_open_aria_undated": "Open letter",
"journey_empty_state": "This reading journey is still empty.",
"journey_interlude_aria_label": "Curator's note",
"journey_selector_aria_live_hint": "Please select a type to continue."
}

View File

@@ -1023,6 +1023,11 @@
"nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias",
"error_geschichte_not_found": "No se encontró la historia.",
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
"error_journey_at_capacity": "El viaje de lectura ya ha alcanzado el máximo de 100 entradas.",
"error_geschichte_type_mismatch": "Esta historia no es un viaje de lectura — los elementos de viaje no están permitidos aquí.",
"journey_item_document_deleted": "[Documento eliminado]",
"geschichten_index_title": "Historias",
"geschichten_new_button": "Nueva historia",
"geschichten_filter_all_pill": "Todas",
@@ -1037,6 +1042,7 @@
"geschichten_published_on": "publicada el {date}",
"geschichten_persons_section": "Personas en esta historia",
"geschichten_documents_section": "Documentos mencionados",
"geschichten_document_link_placeholder": "Abrir documento",
"geschichten_card_heading": "Historias",
"geschichten_card_write_action": "+ Escribir historia",
"geschichten_card_attach_action": "+ Adjuntar historia",
@@ -1153,5 +1159,20 @@
"themen_alle": "Todos los temas",
"themen_leer": "Aún no hay temas.",
"themen_weitere": "+ {count} más",
"themen_dokumente": "{count} documentos"
"themen_dokumente": "{count} documentos",
"journey_badge_list": "VIAJE",
"journey_badge_detail": "VIAJE DE LECTURA",
"journey_selector_question": "¿Qué deseas crear?",
"journey_selector_story_title": "Historia",
"journey_selector_story_desc": "Una historia narrada con imágenes y texto.",
"journey_selector_journey_title": "Viaje de lectura",
"journey_selector_journey_desc": "Una selección curada de cartas con notas.",
"journey_selector_next_btn": "Continuar",
"journey_placeholder_back": "otra selección",
"journey_placeholder_heading": "Editor de viaje de lectura próximamente en #753",
"journey_item_open_aria": "Abrir carta del {date}",
"journey_item_open_aria_undated": "Abrir carta",
"journey_empty_state": "Este viaje de lectura está vacío.",
"journey_interlude_aria_label": "Nota del curador",
"journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar."
}

View File

@@ -52,6 +52,6 @@ describe('DashboardNeedsMetadata', () => {
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
const docs = [makeDoc('d1', 'Only one')];
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
await expect.element(page.getByRole('link', { name: /50/ })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: /Alle 50/ })).toBeInTheDocument();
});
});

View File

@@ -84,6 +84,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/geschichten/{id}/items/reorder": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Reorder journey items
* @description itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request.
*/
put: operations["reorderItems"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents/{id}": {
parameters: {
query?: never;
@@ -420,6 +440,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/geschichten/{id}/items": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["appendItem"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/documents": {
parameters: {
query?: never;
@@ -692,22 +728,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/backfill-titles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["backfillTitles"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/backfill-file-hashes": {
parameters: {
query?: never;
@@ -804,6 +824,22 @@ export interface paths {
patch: operations["update"];
trace?: never;
};
"/api/geschichten/{id}/items/{itemId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["deleteItem"];
options?: never;
head?: never;
patch: operations["updateItemNote"];
trace?: never;
};
"/api/documents/{id}/training-labels": {
parameters: {
query?: never;
@@ -875,7 +911,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["search_1"];
get: operations["search"];
put?: never;
post?: never;
delete?: never;
@@ -1339,7 +1375,7 @@ export interface paths {
path?: never;
cookie?: never;
};
get: operations["search_2"];
get: operations["search_1"];
put?: never;
post?: never;
delete?: never;
@@ -1428,6 +1464,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/documents/conversation": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getConversation"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/dashboard/resume": {
parameters: {
query?: never;
@@ -1690,6 +1742,32 @@ export interface components {
provisional: boolean;
readonly displayName: string;
};
JourneyReorderDTO: {
itemIds?: string[];
};
DocumentSummary: {
/** Format: uuid */
id: string;
title: string;
/** Format: date */
documentDate?: string;
/** Format: date */
documentDateEnd?: string;
/** @enum {string} */
datePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
senderName?: string;
receiverName?: string;
/** Format: int32 */
receiverCount: number;
};
JourneyItemView: {
/** Format: uuid */
id: string;
/** Format: int32 */
position: number;
document?: components["schemas"]["DocumentSummary"];
note?: string;
};
DocumentUpdateDTO: {
title?: string;
/** Format: date */
@@ -1758,7 +1836,6 @@ export interface components {
sender?: components["schemas"]["Person"];
tags?: components["schemas"]["Tag"][];
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
hasTranscription: boolean;
thumbnailUrl?: string;
};
PersonMention: {
@@ -1819,75 +1896,6 @@ export interface components {
/** Format: uuid */
targetId: string;
};
Pageable: {
/** Format: int32 */
page?: number;
/** Format: int32 */
size?: number;
sort?: string[];
};
ActivityActorDTO: {
initials: string;
color: string;
name?: string;
};
DocumentListItem: {
/** Format: uuid */
id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
/** @enum {string} */
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
metaDateEnd?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
/** Format: int32 */
completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][];
/** Format: int64 */
totalElements: number;
/** Format: int32 */
pageNumber: number;
/** Format: int32 */
pageSize: number;
/** Format: int32 */
totalPages: number;
/** Format: int64 */
undatedCount: number;
};
MatchOffset: {
/** Format: int32 */
start: number;
/** Format: int32 */
length: number;
};
SearchMatchData: {
transcriptionSnippet?: string;
titleOffsets: components["schemas"]["MatchOffset"][];
senderMatched: boolean;
matchedReceiverIds: string[];
matchedTagIds: string[];
snippetOffsets: components["schemas"]["MatchOffset"][];
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
CreateRelationshipRequest: {
/** Format: uuid */
relatedPersonId: string;
@@ -2233,6 +2241,9 @@ export interface components {
actorName?: string;
documentTitle?: string;
};
JourneyItemUpdateDTO: {
note?: string;
};
TrainingLabelRequest: {
label?: string;
enrolled?: boolean;
@@ -2273,6 +2284,11 @@ export interface components {
/** Format: int64 */
transcriptionCount: number;
};
ActivityActorDTO: {
initials: string;
color: string;
name?: string;
};
TranscriptionQueueItemDTO: {
/** Format: uuid */
id: string;
@@ -2295,11 +2311,6 @@ export interface components {
color?: string;
/** Format: int32 */
documentCount: number;
/**
* Format: int32
* @description Distinct documents tagged with this tag or any descendant tag (subtree rollup)
*/
subtreeDocumentCount: number;
children?: components["schemas"]["TagTreeNodeDTO"][];
/**
* Format: uuid
@@ -2335,13 +2346,13 @@ export interface components {
lastName?: string;
/** Format: int64 */
documentCount?: number;
alias?: string;
notes?: string;
/** Format: int32 */
birthYear?: number;
/** Format: int32 */
deathYear?: number;
provisional?: boolean;
alias?: string;
personType?: string;
familyMember?: boolean;
};
@@ -2440,6 +2451,8 @@ export interface components {
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -2448,8 +2461,6 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -2472,6 +2483,54 @@ export interface components {
nodes: components["schemas"]["PersonNodeDTO"][];
edges: components["schemas"]["RelationshipDTO"][];
};
AuthorSummary: {
firstName?: string;
lastName?: string;
email: string;
};
GeschichteSummary: {
body?: string;
title: string;
/** Format: uuid */
id: string;
/** @enum {string} */
type: "STORY" | "JOURNEY";
/** @enum {string} */
status: "DRAFT" | "PUBLISHED";
author?: components["schemas"]["AuthorSummary"];
/** Format: date-time */
publishedAt?: string;
};
AuthorView: {
/** Format: uuid */
id: string;
displayName: string;
};
GeschichteView: {
/** Format: uuid */
id: string;
title: string;
body?: string;
/** @enum {string} */
status: "DRAFT" | "PUBLISHED";
/** @enum {string} */
type: "STORY" | "JOURNEY";
author?: components["schemas"]["AuthorView"];
persons: components["schemas"]["PersonView"][];
items: components["schemas"]["JourneyItemView"][];
/** Format: date-time */
publishedAt?: string;
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
PersonView: {
/** Format: uuid */
id: string;
firstName?: string;
lastName?: string;
};
DocumentVersionSummary: {
/** Format: uuid */
id: string;
@@ -2513,6 +2572,63 @@ export interface components {
/** Format: int32 */
totalPages?: number;
};
DocumentListItem: {
/** Format: uuid */
id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
/** @enum {string} */
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
/** Format: date */
metaDateEnd?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
/** Format: int32 */
completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
};
DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][];
/** Format: int64 */
totalElements: number;
/** Format: int32 */
pageNumber: number;
/** Format: int32 */
pageSize: number;
/** Format: int32 */
totalPages: number;
/** Format: int64 */
undatedCount: number;
};
MatchOffset: {
/** Format: int32 */
start: number;
/** Format: int32 */
length: number;
};
SearchMatchData: {
transcriptionSnippet?: string;
titleOffsets: components["schemas"]["MatchOffset"][];
senderMatched: boolean;
matchedReceiverIds: string[];
matchedTagIds: string[];
snippetOffsets: components["schemas"]["MatchOffset"][];
summarySnippet?: string;
summaryOffsets: components["schemas"]["MatchOffset"][];
};
IncompleteDocumentDTO: {
/** Format: uuid */
id: string;
@@ -2561,7 +2677,7 @@ export interface components {
};
ActivityFeedItemDTO: {
/** @enum {string} */
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED";
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED";
actor?: components["schemas"]["ActivityActorDTO"];
/** Format: uuid */
documentId: string;
@@ -2871,6 +2987,32 @@ export interface operations {
};
};
};
reorderItems: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["JourneyReorderDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["JourneyItemView"][];
};
};
};
};
getDocument: {
parameters: {
query?: never;
@@ -3620,6 +3762,32 @@ export interface operations {
};
};
};
appendItem: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["JourneyItemCreateDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["JourneyItemView"];
};
};
};
};
createDocument: {
parameters: {
query?: never;
@@ -4118,26 +4286,6 @@ export interface operations {
};
};
};
backfillTitles: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["BackfillResult"];
};
};
};
};
backfillFileHashes: {
parameters: {
query?: never;
@@ -4291,7 +4439,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Geschichte"];
"*/*": components["schemas"]["GeschichteView"];
};
};
};
@@ -4342,6 +4490,54 @@ export interface operations {
};
};
};
deleteItem: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
itemId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateItemNote: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
itemId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["JourneyItemUpdateDTO"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["JourneyItemView"];
};
};
};
};
patchTrainingLabel: {
parameters: {
query?: never;
@@ -4486,7 +4682,7 @@ export interface operations {
};
};
};
search_1: {
search: {
parameters: {
query?: {
q?: string;
@@ -5110,7 +5306,7 @@ export interface operations {
};
};
};
search_2: {
search_1: {
parameters: {
query?: {
q?: string;
@@ -5280,6 +5476,32 @@ export interface operations {
};
};
};
getConversation: {
parameters: {
query: {
senderId: string;
receiverId?: string;
from?: string;
to?: string;
dir?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["Document"][];
};
};
};
};
getResume: {
parameters: {
query?: never;
@@ -5325,7 +5547,7 @@ export interface operations {
query?: {
limit?: number;
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[];
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[];
};
header?: never;
path?: never;

View File

@@ -6,33 +6,23 @@ import StarterKit from '@tiptap/starter-kit';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
type Geschichte = components['schemas']['Geschichte'];
type Person = components['schemas']['Person'];
type Document = components['schemas']['Document'];
interface Props {
geschichte?: Geschichte | null;
initialPersons?: Person[];
initialDocuments?: Document[];
onSubmit: (payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) => Promise<void>;
submitting?: boolean;
}
let {
geschichte = null,
initialPersons = [],
initialDocuments = [],
onSubmit,
submitting = false
}: Props = $props();
let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Props = $props();
// Initial-state snapshot from incoming props. The editor owns these values
// after mount; the parent should re-mount the component with a different
@@ -44,9 +34,6 @@ let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
let selectedPersons: Person[] = $state(
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
);
let selectedDocuments: Document[] = $state(
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
);
let dirty = $state(false);
let titleTouched = $state(false);
@@ -122,8 +109,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
title: title.trim(),
body,
status: nextStatus,
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
});
dirty = false;
}
@@ -269,14 +255,6 @@ function exec(action: () => void) {
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
</section>
<section class="rounded border border-line bg-surface p-4 shadow-sm">
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.geschichte_editor_dokumente_heading()}
</h2>
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
</section>
</aside>
</div>

View File

@@ -8,19 +8,9 @@ const personFactory = (id: string, displayName: string) => ({
firstName: displayName.split(' ')[0],
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
displayName,
personType: 'PERSON' as const
});
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
id,
title,
documentDate: date,
originalFilename: `${title}.pdf`,
status: 'UPLOADED' as const,
metadataComplete: false,
scriptType: 'UNKNOWN' as const,
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
personType: 'PERSON' as const,
familyMember: false,
provisional: false
});
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
@@ -28,8 +18,9 @@ const draftFactory = (overrides: Record<string, unknown> = {}) => ({
title: 'Existing draft',
body: '<p>Hello world</p>',
status: 'DRAFT' as const,
type: 'STORY' as const,
persons: [],
documents: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00',
...overrides
@@ -93,14 +84,6 @@ describe('GeschichteEditor — pre-fill', () => {
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
});
it('renders initial documents as chips', async () => {
render(GeschichteEditor, {
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
onSubmit: vi.fn()
});
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
});
it('populates the title input from a geschichte prop', async () => {
render(GeschichteEditor, {
geschichte: draftFactory({ title: 'My existing story' }),
@@ -154,11 +137,10 @@ describe('GeschichteEditor — onSubmit payload', () => {
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
});
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
it('passes personIds from initial props through onSubmit', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(GeschichteEditor, {
initialPersons: [personFactory('p1', 'Franz Raddatz')],
initialDocuments: [docFactory('d1', 'Brief A')],
onSubmit
});
@@ -171,6 +153,5 @@ describe('GeschichteEditor — onSubmit payload', () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
const payload = onSubmit.mock.calls[0][0];
expect(payload.personIds).toEqual(['p1']);
expect(payload.documentIds).toEqual(['d1']);
});
});

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/shared/utils/extractText';
import { formatAuthorName, formatPublishedAt } from './utils';
import type { components } from '$lib/generated/api';
type GeschichteRow = Pick<
components['schemas']['GeschichteSummary'],
'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'
>;
let { geschichte }: { geschichte: GeschichteRow } = $props();
const isJourney = $derived(geschichte.type === 'JOURNEY');
const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short'));
const authorName = $derived(formatAuthorName(geschichte.author));
</script>
<a href="/geschichten/{geschichte.id}" class="block">
<div class="mb-1 flex items-center gap-1.5">
<h2 class="font-serif text-xl font-bold text-ink">{geschichte.title}</h2>
{#if isJourney}
<span
data-testid="journey-badge"
style="font-size: 0.75rem"
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase"
>
{m.journey_badge_list()}
</span>
{/if}
</div>
<p class="mb-3 font-sans text-xs text-ink-3">
{authorName}
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
</p>
{#if geschichte.body}
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
<p class="font-serif text-base text-ink-2">{plainExcerpt(geschichte.body, 150)}</p>
{/if}
</a>

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
const { default: GeschichteListRow } = await import('./GeschichteListRow.svelte');
afterEach(cleanup);
const baseRow = (overrides = {}) => ({
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>',
type: 'STORY' as 'STORY' | 'JOURNEY',
status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' },
publishedAt: '2026-04-15T10:00:00Z',
...overrides
});
describe('GeschichteListRow', () => {
it('renders the title', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
await expect
.element(page.getByRole('heading', { level: 2 }))
.toHaveTextContent('Die Reise nach Berlin');
});
it('shows no badge for STORY type', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } });
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
});
it('shows no badge when type is undefined', async () => {
render(GeschichteListRow, {
props: { geschichte: baseRow({ type: undefined as unknown as 'STORY' | 'JOURNEY' }) }
});
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
});
it('shows REISE badge for JOURNEY type', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
const badge = document.querySelector('[data-testid="journey-badge"]');
expect(badge).not.toBeNull();
expect(badge?.textContent?.trim()).toBe('REISE');
});
it('badge is a plain <span>, not a nested interactive element', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
const badge = document.querySelector('[data-testid="journey-badge"]');
expect(badge?.tagName.toLowerCase()).toBe('span');
});
it('badge has small font size appropriate for a label', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
const badge = document.querySelector('[data-testid="journey-badge"]');
const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize);
expect(fontSize).toBeGreaterThan(0);
expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size
});
it('renders author name in meta line', async () => {
render(GeschichteListRow, { props: { geschichte: baseRow() } });
expect(document.body.textContent).toContain('Anna Schmidt');
});
});

View File

@@ -4,10 +4,10 @@ import type { components } from '$lib/generated/api';
import { plainExcerpt } from '$lib/shared/utils/extractText';
import { formatDate } from '$lib/shared/utils/date';
type Geschichte = components['schemas']['Geschichte'];
type GeschichteSummary = components['schemas']['GeschichteSummary'];
interface Props {
geschichten: Geschichte[];
geschichten: GeschichteSummary[];
personId: string;
personName: string;
canWrite: boolean;
@@ -18,12 +18,12 @@ let { geschichten, personId, personName, canWrite }: Props = $props();
const visible = $derived(geschichten.slice(0, 3));
const hasOverflow = $derived(geschichten.length >= 3);
function formatPublishedDate(g: Geschichte): string | null {
function formatPublishedDate(g: GeschichteSummary): string | null {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
function authorName(g: Geschichte): string {
function authorName(g: GeschichteSummary): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();

View File

@@ -3,16 +3,17 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
const makeStory = (id: string, title: string, body: string | undefined = '<p>Body</p>') => ({
id,
title,
body,
status: 'PUBLISHED' as const,
type: 'STORY' as const,
publishedAt: '2024-04-01T12:00:00',
createdAt: '2024-03-01T12:00:00',
updatedAt: '2024-04-01T12:00:00',
persons: [],
documents: [],
items: [],
author: {
id: 'u1',
email: 'marcel@example.com',
@@ -120,6 +121,16 @@ describe('GeschichtenCard', () => {
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
});
it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => {
render(GeschichtenCard, {
geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }],
personId: 'p1',
personName: 'Franz',
canWrite: false
});
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
});
it('renders a plain-text excerpt without HTML markup', async () => {
render(GeschichtenCard, {
geschichten: [

View File

@@ -2,20 +2,30 @@ import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import GeschichtenCard from './GeschichtenCard.svelte';
import type { components } from '$lib/generated/api';
type GeschichteSummary = components['schemas']['GeschichteSummary'];
afterEach(cleanup);
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
id: 'g1',
title: 'Reise nach Berlin',
body: '<p>Brief text</p>',
publishedAt: '2026-04-15T10:00:00Z',
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@b' } as unknown,
...overrides
});
const makeGeschichte = (overrides: Record<string, unknown> = {}): GeschichteSummary =>
({
id: 'g1',
title: 'Reise nach Berlin',
body: '<p>Brief text</p>',
status: 'PUBLISHED' as const,
type: 'STORY' as const,
publishedAt: '2026-04-15T10:00:00Z',
author: {
email: 'a@b',
firstName: 'Anna',
lastName: 'Schmidt'
},
...overrides
}) as GeschichteSummary;
const baseProps = (overrides: Record<string, unknown> = {}) => ({
geschichten: [] as ReturnType<typeof makeGeschichte>[],
geschichten: [] as GeschichteSummary[],
personId: 'p-1',
personName: 'Anna Schmidt',
canWrite: false,

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
note: string;
}
let { note }: Props = $props();
</script>
<div
role="note"
aria-label={m.journey_interlude_aria_label()}
class="my-2 border-l-4 border-journey-border bg-journey-tint px-4 py-3"
>
<p
class="text-center font-sans text-xs tracking-widest text-journey uppercase"
aria-hidden="true"
>
</p>
<!-- plaintext — do NOT use {@html} here -->
<p class="font-serif text-base leading-relaxed text-ink-2 italic">{note}</p>
</div>

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
const { default: JourneyInterlude } = await import('./JourneyInterlude.svelte');
afterEach(cleanup);
declare global {
interface Window {
__xss_interlude?: number;
}
}
describe('JourneyInterlude', () => {
it('renders the note text as plaintext', async () => {
render(JourneyInterlude, { props: { note: 'Eine kurze Pause auf der Reise.' } });
await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible();
});
it('has aria-label from i18n (journey_interlude_aria_label)', async () => {
render(JourneyInterlude, { props: { note: 'Notiz' } });
const el = document.querySelector(`[aria-label="${m.journey_interlude_aria_label()}"]`);
expect(el).not.toBeNull();
});
it('has role="note" so the aria-label is announced by screen readers', async () => {
render(JourneyInterlude, { props: { note: 'Notiz' } });
const el = document.querySelector('[role="note"]');
expect(el).not.toBeNull();
expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label());
});
it('renders the section-break glyph ❦', async () => {
render(JourneyInterlude, { props: { note: 'Notiz' } });
expect(document.body.textContent).toContain('❦');
});
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
// Interlude uses Svelte text interpolation ({note}), NOT {@html}.
render(JourneyInterlude, {
props: { note: '<img src=x onerror="window.__xss_interlude=1">' }
});
expect(window.__xss_interlude).toBeUndefined();
expect(document.body.textContent).toContain('<img src=x onerror=');
});
});

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/shared/utils/date';
import type { components } from '$lib/generated/api';
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
item: JourneyItemView;
}
let { item }: Props = $props();
// Safe: JourneyReader filters out items where document === null before rendering this component.
const doc = $derived(item.document!);
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
const ariaLabel = $derived(
formattedDate
? m.journey_item_open_aria({ date: formattedDate })
: m.journey_item_open_aria_undated()
);
const hasNote = $derived(item.note != null && item.note.trim().length > 0);
</script>
<a
href="/documents/{doc.id}"
aria-label={ariaLabel}
style="display: flex; min-height: 44px; flex-direction: column"
class="flex min-h-[44px] flex-col gap-1 rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<span class="font-bold">{doc.title}</span>
{#if formattedDate}
<span class="font-sans text-sm text-ink-3">{formattedDate}</span>
{/if}
</a>
{#if hasNote}
<!-- plaintext — do NOT use {@html} here -->
<p class="mt-1 flex items-baseline gap-1 font-sans text-sm text-ink-3">
<span aria-hidden="true"></span>
{item.note}
</p>
{/if}

View File

@@ -0,0 +1,124 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import type { components } from '$lib/generated/api';
const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte');
afterEach(cleanup);
declare global {
interface Window {
__xss_note?: number;
}
}
type JourneyItemView = components['schemas']['JourneyItemView'];
const baseItem = (overrides: Partial<JourneyItemView> = {}): JourneyItemView => ({
id: 'item1',
position: 0,
document: {
id: 'd1',
title: 'Brief an Helene',
documentDate: '1923-05-15',
datePrecision: 'FULL'
},
...overrides
});
describe('JourneyItemCard', () => {
it('renders the document title', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
});
it('renders the document date when documentDate is present', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
await expect.element(page.getByText(/1923/)).toBeVisible();
});
it('whole card is a single <a> element', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = document.querySelector('a');
expect(link).not.toBeNull();
expect(link?.href).toContain('/documents/d1');
});
it('link has dated aria-label when documentDate is present', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = document.querySelector('a');
expect(link?.getAttribute('aria-label')).toContain('Brief');
expect(link?.getAttribute('aria-label')).toContain('1923');
});
it('link has undated aria-label when documentDate is absent', async () => {
render(JourneyItemCard, {
props: {
item: baseItem({
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
})
}
});
const link = document.querySelector('a');
expect(link?.getAttribute('aria-label')).toBe('Brief öffnen');
});
it('omits date text when documentDate is absent', async () => {
render(JourneyItemCard, {
props: {
item: baseItem({
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
})
}
});
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
});
it('renders ✎ glyph and note text when note is present', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
expect(document.body.textContent).toContain('✎');
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
});
it('omits annotation block when note is blank or whitespace', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
expect(document.body.textContent).not.toContain('✎');
});
it('omits annotation block when note is absent', async () => {
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
expect(document.body.textContent).not.toContain('✎');
});
it('link meets 44px touch-target minimum height', async () => {
render(JourneyItemCard, { props: { item: baseItem() } });
const link = document.querySelector('a');
const rect = link?.getBoundingClientRect();
expect(rect?.height).toBeGreaterThanOrEqual(44);
});
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
// Note uses Svelte text interpolation ({note}), NOT {@html}.
render(JourneyItemCard, {
props: {
item: baseItem({
note: '<img src=x onerror="window.__xss_note=1">'
})
}
});
expect(window.__xss_note).toBeUndefined();
expect(document.body.textContent).toContain('<img src=x onerror=');
});
});

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import JourneyItemCard from './JourneyItemCard.svelte';
import JourneyInterlude from './JourneyInterlude.svelte';
import type { components } from '$lib/generated/api';
type GeschichteView = components['schemas']['GeschichteView'];
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
geschichte: GeschichteView;
canBlogWrite: boolean;
ondelete?: () => Promise<void>;
}
let { geschichte: g, canBlogWrite, ondelete }: Props = $props();
// Render intro only when body is a non-empty, non-whitespace string.
const introText = $derived(g.body?.trim() ? g.body : null);
// Omit items that have neither a document nor a non-blank note (dangling deleted-document guard).
const validItems = $derived(
g.items.filter(
(item: JourneyItemView) =>
item.document != null || (item.note != null && item.note.trim().length > 0)
)
);
</script>
{#if introText}
<!-- plaintext — do NOT use {@html} here -->
<p class="mb-8 font-serif text-base leading-relaxed text-ink-2 italic">{introText}</p>
{/if}
{#if validItems.length === 0}
<p class="font-sans text-sm text-ink-3" data-testid="journey-empty-state">
{m.journey_empty_state()}
</p>
{:else}
<ol class="flex list-none flex-col gap-4">
{#each validItems as item (item.id)}
<li>
{#if item.document != null}
<JourneyItemCard item={item} />
{:else}
<JourneyInterlude note={item.note!} />
{/if}
</li>
{/each}
</ol>
{/if}
<!-- Author actions -->
{#if canBlogWrite}
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
<a
href="/geschichten/{g.id}/edit"
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.btn_edit()}
</a>
<button
type="button"
onclick={() => ondelete?.()}
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.btn_delete()}
</button>
</div>
{/if}

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import type { components } from '$lib/generated/api';
const { default: JourneyReader } = await import('./JourneyReader.svelte');
afterEach(cleanup);
declare global {
interface Window {
__xss_journey?: number;
}
}
type GeschichteView = components['schemas']['GeschichteView'];
type JourneyItemView = components['schemas']['JourneyItemView'];
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
id: 'g1',
title: 'Lesereise Berlin',
body: null as unknown as undefined,
type: 'JOURNEY',
status: 'PUBLISHED',
persons: [],
items: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides
});
const docItem = (id: string, title: string, position: number, note?: string): JourneyItemView => ({
id,
position,
document: { id: `d${id}`, title, datePrecision: 'FULL', documentDate: '1923-05-15' },
note
});
const interludeItem = (id: string, note: string, position: number): JourneyItemView => ({
id,
position,
document: undefined,
note
});
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
describe('JourneyReader', () => {
it('renders intro paragraph when body is non-empty', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({ body: 'Eine Reise durch die Geschichte.' }),
canBlogWrite: false
}
});
await expect.element(page.getByText('Eine Reise durch die Geschichte.')).toBeVisible();
});
it('omits intro paragraph when body is null', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ body: undefined }), canBlogWrite: false }
});
// Only empty state should render
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
});
it('omits intro paragraph when body is only whitespace', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ body: ' ' }), canBlogWrite: false }
});
// Whitespace-only body must NOT produce a visible intro paragraph.
// The only rendered content should be the empty-state message.
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
const paragraphs = document.querySelectorAll('p:not([data-testid])');
expect(paragraphs.length).toBe(0);
});
it('renders empty-state message when items array is empty', async () => {
render(JourneyReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false }
});
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
});
it('renders both intro and empty-state when body is set but items is empty', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
body: 'Eine Einleitung.',
items: []
}),
canBlogWrite: false
}
});
await expect.element(page.getByText('Eine Einleitung.')).toBeVisible();
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeVisible();
});
it('renders document items (JourneyItemCard)', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({ items: [docItem('item1', 'Brief an Helene', 0)] }),
canBlogWrite: false
}
});
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
});
it('renders interlude items (JourneyInterlude)', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({ items: [interludeItem('inter1', 'Eine Pause.', 0)] }),
canBlogWrite: false
}
});
await expect.element(page.getByText('Eine Pause.')).toBeVisible();
expect(document.body.textContent).toContain('❦');
});
it('omits items where document is null AND note is blank (dangling-item rule)', async () => {
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
items: [
{ id: 'dangling', position: 0, document: undefined, note: ' ' },
docItem('item2', 'Echter Brief', 1)
]
}),
canBlogWrite: false
}
});
await expect.element(page.getByText('Echter Brief')).toBeVisible();
// Empty-state must NOT render when valid items exist
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
});
it('clicking delete button calls ondelete prop', async () => {
const ondelete = vi.fn().mockResolvedValue(undefined);
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({ items: [docItem('i1', 'Brief', 0)] }),
canBlogWrite: true,
ondelete
}
});
await userEvent.click(page.getByRole('button', { name: /löschen/i }));
expect(ondelete).toHaveBeenCalledOnce();
});
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
// JourneyReader uses Svelte text interpolation, NOT {@html}.
render(JourneyReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
body: '<img src=x onerror="window.__xss_journey=1">'
}),
canBlogWrite: false
}
});
expect(window.__xss_journey).toBeUndefined();
expect(document.body.textContent).toContain('<img src=x onerror=');
});
});

View File

@@ -1,10 +1,11 @@
# geschichte (frontend)
UI for family stories: the rich-text editor, story cards, and story list view.
UI for family stories (Geschichte) and reading journeys (Lesereise): the rich-text editor, story/journey readers, type badge, and list rows.
## What this domain owns
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`, `GeschichteListRow.svelte`, `StoryReader.svelte`, `JourneyReader.svelte`, `JourneyItemCard.svelte`, `JourneyInterlude.svelte`.
Utilities: `utils.ts`.
## What this domain does NOT own
@@ -14,14 +15,38 @@ Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
## Key components
| Component | Used in | Notes |
| ------------------------- | -------------------------------------------- | ------------------------------------------------------------------ |
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds |
| `GeschichtenCard.svelte` | `/geschichten` (list), dashboard | Story preview card with cover image and publish status |
| Component | Used in | Notes |
| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds |
| `GeschichtenCard.svelte` | Person-detail sidebar | Sidebar card showing the 3 most recent stories linked to a person — NOT the list page |
| `GeschichteListRow.svelte` | `/geschichten` (list) | Row component for the list; shows JOURNEY type badge (`text-xs` orange pill) |
| `StoryReader.svelte` | `/geschichten/[id]` (STORY branch) | Renders sanitised rich-text body, persons section, documents section, and author actions |
| `JourneyReader.svelte` | `/geschichten/[id]` (JOURNEY branch) | Renders intro paragraph, ordered items list, empty-state; delegates to ItemCard/Interlude |
| `JourneyItemCard.svelte` | `JourneyReader.svelte` | Whole-card `<a>` for a document item; dated/undated aria-label, ✎ annotation glyph |
| `JourneyInterlude.svelte` | `JourneyReader.svelte` | Typographic aside between letters; ❦ glyph, `aria-label="Kuratorennotiz"` |
## utils.ts
`formatAuthorName(author)` — joins `firstName + lastName`, falls back to `email` (for list/summary shapes).
`formatAuthorDisplayName(author)` — returns `displayName` (for detail `AuthorView` shape).
`formatPublishedAt(publishedAt, style)` — wraps `formatDate` with null check; `style` is `'short'` (list) or `'long'` (detail).
## Public list is PUBLISHED-only
`GET /api/geschichten` constrains `status=PUBLISHED`, so DRAFT journeys never appear in the list.
The REISE badge is only ever seen for published journeys.
Empty-state and draft-preview paths are reachable ONLY via the **detail route** (`/geschichten/[id]`), not the list.
Wire empty-state E2E tests through the detail route, not by expecting a draft journey in the list.
## TypeSelector route component
`TypeSelector.svelte` lives in `src/routes/geschichten/new/` (single-use route UI).
It is NOT in `$lib/geschichte/` — route-specific, not reused elsewhere.
`StoryCreate.svelte` (also in `new/`) wraps `GeschichteEditor` so tree-shaking excludes TipTap from the JOURNEY placeholder path.
## Audience note
The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone.
The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone. JourneyReader mobile layout is Critical; TypeSelector is Minor.
## Cross-domain imports

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { safeHtml } from '$lib/shared/utils/sanitize';
import type { components } from '$lib/generated/api';
type GeschichteView = components['schemas']['GeschichteView'];
interface Props {
geschichte: GeschichteView;
canBlogWrite: boolean;
ondelete?: () => Promise<void>;
}
let { geschichte: g, canBlogWrite, ondelete }: Props = $props();
const sanitized = $derived(safeHtml(g.body));
function personName(p: { firstName?: string; lastName?: string }): string {
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
}
</script>
<!--
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
and produces a much narrower column inside an already narrow page, which
Leonie flagged as unreadable for the senior-author persona.
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
-->
<div
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html sanitized}
</div>
<!-- Personen -->
{#if g.persons && g.persons.length > 0}
<section class="mt-10 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.geschichten_persons_section()}
</h2>
<ul class="flex flex-wrap gap-2">
{#each g.persons as p (p.id)}
<li>
<a
href="/persons/{p.id}"
style="display: inline-flex; min-height: 44px"
class="inline-flex min-h-[44px] items-center rounded-full bg-muted px-3 py-2.5 font-sans text-sm text-ink hover:bg-accent-bg focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{personName(p)}
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- Dokumente (JourneyItems) -->
{#if g.items && g.items.some((i) => i.document)}
<section class="mt-8 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.geschichten_documents_section()}
</h2>
<ul class="flex flex-col gap-2">
{#each g.items.filter((i) => i.document) as item (item.id)}
<li>
<a
href="/documents/{item.document!.id}"
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.geschichten_document_link_placeholder()}
</a>
{#if item.note}
<!-- plaintext — do NOT use {@html} here -->
<p class="mt-1 font-sans text-sm text-ink-3">{item.note}</p>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
<!-- Author actions -->
{#if canBlogWrite}
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
<a
href="/geschichten/{g.id}/edit"
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.btn_edit()}
</a>
<button
type="button"
onclick={() => ondelete?.()}
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.btn_delete()}
</button>
</div>
{/if}

View File

@@ -0,0 +1,163 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import type { components } from '$lib/generated/api';
const { default: StoryReader } = await import('./StoryReader.svelte');
afterEach(cleanup);
type GeschichteView = components['schemas']['GeschichteView'];
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
type: 'STORY',
status: 'PUBLISHED',
author: { id: 'u1', displayName: 'Anna Schmidt' },
persons: [],
items: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...overrides
});
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
describe('StoryReader', () => {
it('renders body HTML content', async () => {
render(StoryReader, {
context: ctx(),
props: { geschichte: baseGeschichte(), canBlogWrite: false }
});
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
});
it('omits persons section when persons array is empty', async () => {
render(StoryReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ persons: [] }), canBlogWrite: false }
});
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
});
it('renders persons section with firstName + lastName joined', async () => {
render(StoryReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
persons: [
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
]
}),
canBlogWrite: false
}
});
await expect.element(page.getByText('Personen in dieser Geschichte')).toBeVisible();
await expect.element(page.getByText('Helene Schmidt')).toBeVisible();
await expect.element(page.getByText('Karl Müller')).toBeVisible();
});
it('omits documents section when no items have documents', async () => {
render(StoryReader, {
context: ctx(),
props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false }
});
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
});
it('renders documents section for items with documents', async () => {
render(StoryReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
items: [
{
id: 'i1',
position: 0,
document: { id: 'd1', title: 'Brief 1', datePrecision: 'FULL' },
note: 'Wichtiger Brief'
}
]
}),
canBlogWrite: false
}
});
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
await expect.element(page.getByText('Dokument öffnen')).toBeVisible();
await expect.element(page.getByText('Wichtiger Brief')).toBeVisible();
});
it('shows edit/delete actions when canBlogWrite is true', async () => {
render(StoryReader, {
context: ctx(),
props: { geschichte: baseGeschichte(), canBlogWrite: true }
});
await expect
.element(page.getByRole('link', { name: /bearbeiten/i }))
.toHaveAttribute('href', '/geschichten/g1/edit');
await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible();
});
it('hides edit/delete actions when canBlogWrite is false', async () => {
render(StoryReader, {
context: ctx(),
props: { geschichte: baseGeschichte(), canBlogWrite: false }
});
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
});
it('clicking delete button calls ondelete prop', async () => {
const ondelete = vi.fn().mockResolvedValue(undefined);
render(StoryReader, {
context: ctx(),
props: { geschichte: baseGeschichte(), canBlogWrite: true, ondelete }
});
await userEvent.click(page.getByRole('button', { name: /löschen/i }));
expect(ondelete).toHaveBeenCalledOnce();
});
it('person chip link meets 44px touch-target minimum height', async () => {
render(StoryReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
}),
canBlogWrite: false
}
});
const link = document.querySelector<HTMLAnchorElement>('a[href^="/persons/"]');
const rect = link?.getBoundingClientRect();
expect(rect?.height).toBeGreaterThanOrEqual(44);
});
it('XSS: Story body is sanitised — injected payload does not execute', async () => {
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
render(StoryReader, {
context: ctx(),
props: {
geschichte: baseGeschichte({
body: '<img src=x onerror="(window as any).__xss_story=1">'
}),
canBlogWrite: false
}
});
expect((window as { __xss_story?: number }).__xss_story).toBeUndefined();
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './utils';
describe('formatAuthorName', () => {
it('joins firstName and lastName with a space', () => {
expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' })).toBe(
'Anna Schmidt'
);
});
it('returns firstName alone when lastName is absent', () => {
expect(formatAuthorName({ firstName: 'Anna', email: 'a@x' })).toBe('Anna');
});
it('returns lastName alone when firstName is absent', () => {
expect(formatAuthorName({ lastName: 'Schmidt', email: 'a@x' })).toBe('Schmidt');
});
it('falls back to email when both names are absent', () => {
expect(formatAuthorName({ email: 'fallback@example.com' })).toBe('fallback@example.com');
});
it('returns empty string for null input', () => {
expect(formatAuthorName(null)).toBe('');
});
it('returns empty string for undefined input', () => {
expect(formatAuthorName(undefined)).toBe('');
});
});
describe('formatAuthorDisplayName', () => {
it('returns displayName when present', () => {
expect(formatAuthorDisplayName({ displayName: 'Anna Schmidt' })).toBe('Anna Schmidt');
});
it('returns empty string for null input', () => {
expect(formatAuthorDisplayName(null)).toBe('');
});
it('returns empty string for undefined input', () => {
expect(formatAuthorDisplayName(undefined)).toBe('');
});
});
describe('formatPublishedAt', () => {
it('returns null for null input', () => {
expect(formatPublishedAt(null)).toBeNull();
});
it('returns null for undefined input', () => {
expect(formatPublishedAt(undefined)).toBeNull();
});
it('formats an ISO datetime string to a localised date', () => {
const result = formatPublishedAt('2026-04-15T10:00:00Z', 'short');
expect(result).not.toBeNull();
expect(result).toContain('2026');
});
it('slices to date-only before formatting (no TZ off-by-one)', () => {
// Both dates should format identically regardless of timezone offset
const a = formatPublishedAt('2026-04-15T00:00:00Z', 'short');
const b = formatPublishedAt('2026-04-15T23:59:59Z', 'short');
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,22 @@
import { formatDate } from '$lib/shared/utils/date';
type AuthorSummary = { firstName?: string; lastName?: string; email: string };
type AuthorView = { displayName: string };
export function formatAuthorName(author: AuthorSummary | null | undefined): string {
if (!author) return '';
const full = [author.firstName, author.lastName].filter(Boolean).join(' ').trim();
return full || author.email || '';
}
export function formatAuthorDisplayName(author: AuthorView | null | undefined): string {
return author?.displayName ?? '';
}
export function formatPublishedAt(
publishedAt: string | null | undefined,
style: 'short' | 'long' = 'short'
): string | null {
if (!publishedAt) return null;
return formatDate(publishedAt.slice(0, 10), style);
}

View File

@@ -18,9 +18,8 @@ export function radioGroupNav(
const delta = event.key === 'ArrowRight' ? 1 : -1;
const next = (current + delta + radios.length) % radios.length;
radios[current].setAttribute('aria-checked', 'false');
radios[next].setAttribute('aria-checked', 'true');
radios[next].focus();
radios.forEach((r, i) => r.setAttribute('aria-checked', i === next ? 'true' : 'false'));
onChangeFn?.(radios[next].getAttribute('value') ?? '');
}

View File

@@ -46,6 +46,10 @@ export type ErrorCode =
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'GESCHICHTE_NOT_FOUND'
| 'JOURNEY_ITEM_NOT_FOUND'
| 'JOURNEY_ITEM_POSITION_CONFLICT'
| 'JOURNEY_AT_CAPACITY'
| 'GESCHICHTE_TYPE_MISMATCH'
| 'INVALID_CREDENTIALS'
| 'SESSION_EXPIRED'
| 'MISSING_CREDENTIALS'
@@ -164,6 +168,14 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_duplicate_relationship();
case 'GESCHICHTE_NOT_FOUND':
return m.error_geschichte_not_found();
case 'JOURNEY_ITEM_NOT_FOUND':
return m.error_journey_item_not_found();
case 'JOURNEY_ITEM_POSITION_CONFLICT':
return m.error_journey_item_position_conflict();
case 'JOURNEY_AT_CAPACITY':
return m.error_journey_at_capacity();
case 'GESCHICHTE_TYPE_MISMATCH':
return m.error_geschichte_type_mismatch();
case 'INVALID_CREDENTIALS':
return m.error_invalid_credentials();
case 'SESSION_EXPIRED':

View File

@@ -48,6 +48,18 @@ describe('extractText', () => {
});
});
// SSR regex-fallback XSS gate — must stay in the Node (.test.ts / .spec.ts) project.
// The browser project's DOMParser would silently take the safe branch → false green.
// This test fires the regex fallback specifically (Node has no DOMParser).
describe('plainExcerpt — SSR regex-fallback XSS gate (Node tier)', () => {
it('does not emit onerror= in output when given an <img onerror> payload (security regression)', () => {
// plainExcerpt calls extractText which regex-strips tags in Node (no DOMParser).
// SvelteKit SSR auto-escapes the result, so onerror= in output is the first-paint risk.
const out = plainExcerpt('<img src=x onerror="window.__xss=1">');
expect(out).not.toContain('onerror=');
});
});
describe('plainExcerpt', () => {
it('returns full text when under the limit', () => {
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');

View File

@@ -9,15 +9,13 @@ type Person = components['schemas']['Person'];
export const load: PageServerLoad = async ({ url, fetch }) => {
const api = createApiClient(fetch);
const personIds = url.searchParams.getAll('personId');
const documentId = url.searchParams.get('documentId') ?? undefined;
const [listResult, ...personResults] = await Promise.all([
api.GET('/api/geschichten', {
params: {
query: {
status: 'PUBLISHED',
personId: personIds.length ? personIds : undefined,
documentId
personId: personIds.length ? personIds : undefined
}
}
}),
@@ -34,7 +32,6 @@ export const load: PageServerLoad = async ({ url, fetch }) => {
return {
geschichten: listResult.data ?? [],
personFilters,
documentFilter: documentId ?? null
personFilters
};
};

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { plainExcerpt } from '$lib/shared/utils/extractText';
import { formatDate } from '$lib/shared/utils/date';
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
import GeschichteListRow from '$lib/geschichte/GeschichteListRow.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -11,12 +10,11 @@ let { data }: { data: PageData } = $props();
let showPersonPicker = $state(false);
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter);
const hasFilters = $derived(data.personFilters.length > 0);
function rebuildUrl(personIds: string[]) {
const url = new URL(window.location.href);
url.searchParams.delete('personId');
url.searchParams.delete('documentId');
for (const id of personIds) url.searchParams.append('personId', id);
return url.pathname + url.search;
}
@@ -37,18 +35,6 @@ function addPerson(personId: string) {
function removePerson(personId: string) {
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
}
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
function publishedAt(g: { publishedAt?: string }): string | null {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'short');
}
</script>
<div class="mx-auto max-w-4xl px-4 py-8">
@@ -132,16 +118,8 @@ function publishedAt(g: { publishedAt?: string }): string | null {
<li
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
>
<a href="/geschichten/{g.id}" class="block">
<h2 class="mb-1 font-serif text-xl font-bold text-ink">{g.title}</h2>
<p class="mb-3 font-sans text-xs text-ink-3">
{authorName(g)}
{#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if}
</p>
{#if g.body}
<p class="font-serif text-base text-ink-2">{plainExcerpt(g.body, 150)}</p>
{/if}
</a>
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
<GeschichteListRow geschichte={g} />
</li>
{/each}
</ul>

View File

@@ -1,33 +1,34 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { safeHtml } from '$lib/shared/utils/sanitize';
import { formatDate } from '$lib/shared/utils/date';
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { csrfFetch } from '$lib/shared/cookies';
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import StoryReader from '$lib/geschichte/StoryReader.svelte';
import JourneyReader from '$lib/geschichte/JourneyReader.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const g = $derived(data.geschichte);
const sanitized = $derived(safeHtml(g.body));
const isJourney = $derived(g.type === 'JOURNEY');
const publishedAt = $derived.by(() => {
if (!g.publishedAt) return null;
return formatDate(g.publishedAt.slice(0, 10), 'long');
});
function authorName(): string {
const a = g.author;
if (!a) return '';
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
return full || a.email || '';
}
const authorName = $derived(formatAuthorDisplayName(g.author));
const confirm = getConfirmService();
let deleteError = $state<string | null>(null);
async function handleDelete() {
deleteError = null;
const ok = await confirm.confirm({
title: m.geschichte_delete_confirm_title(),
body: m.geschichte_delete_confirm_body(),
@@ -39,6 +40,9 @@ async function handleDelete() {
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
if (res.ok) {
goto('/geschichten');
} else {
const err = await parseBackendError(res);
deleteError = getErrorMessage(err?.code);
}
}
</script>
@@ -50,94 +54,37 @@ async function handleDelete() {
<article aria-labelledby="geschichte-title">
<header class="mb-6">
<h1 id="geschichte-title" class="mb-3 font-serif text-4xl font-bold text-ink">
{g.title}
</h1>
<div class="mb-3 flex flex-wrap items-center gap-2">
<h1 id="geschichte-title" class="font-serif text-4xl font-bold text-ink">
{g.title}
</h1>
{#if isJourney}
<span
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase"
>
{m.journey_badge_detail()}
</span>
{/if}
</div>
<p class="font-sans text-sm text-ink-3">
{authorName()}
{authorName}
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
</p>
</header>
<!--
Body styles are explicit (no `prose`) so the text uses the full max-w-3xl
parent width — Tailwind Typography's default `max-w-prose` clamps to ~65ch
and produces a much narrower column inside an already narrow page, which
Leonie flagged as unreadable for the senior-author persona.
{#if deleteError}
<p
role="alert"
class="mb-4 rounded border border-danger/30 bg-danger/10 px-4 py-3 font-sans text-sm text-danger"
>
{deleteError}
</p>
{/if}
Sanitised via safeHtml() (DOMPurify) on render — matches backend OWASP allow-list.
-->
<div
class="font-serif text-lg leading-relaxed text-ink [&_h2]:mt-8 [&_h2]:mb-3 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:mb-2 [&_h3]:text-xl [&_h3]:font-bold [&_li]:mb-1 [&_ol]:mb-4 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:mb-4 [&_ul]:mb-4 [&_ul]:list-disc [&_ul]:pl-6"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html sanitized}
</div>
{#if isJourney}
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
{:else}
<StoryReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
{/if}
</article>
<!-- Personen -->
{#if g.persons && g.persons.length > 0}
<section class="mt-10 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.geschichten_persons_section()}
</h2>
<ul class="flex flex-wrap gap-2">
{#each g.persons as p (p.id)}
<li>
<a
href="/persons/{p.id}"
class="inline-flex items-center rounded-full bg-muted px-3 py-1 font-sans text-sm text-ink hover:bg-accent-bg"
>
{p.displayName}
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- Dokumente -->
{#if g.documents && g.documents.length > 0}
<section class="mt-8 border-t border-line pt-6">
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.geschichten_documents_section()}
</h2>
<ul class="flex flex-col gap-2">
{#each g.documents as d (d.id)}
<li>
<a
href="/documents/{d.id}"
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted"
>
{d.title}
{#if d.documentDate}
<span class="ml-2 font-sans text-xs text-ink-3">
{formatDate(d.documentDate, 'short')}
</span>
{/if}
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- Author actions -->
{#if data.canBlogWrite}
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
<a
href="/geschichten/{g.id}/edit"
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.btn_edit()}
</a>
<button
type="button"
onclick={handleDelete}
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{m.btn_delete()}
</button>
</div>
{/if}
</div>

View File

@@ -17,7 +17,6 @@ async function handleSubmit(payload: {
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) {
submitting = true;
errorMessage = null;

View File

@@ -1,25 +1,51 @@
import { describe, it, expect, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import { page, userEvent } from 'vitest/browser';
vi.mock('$app/navigation', () => ({
beforeNavigate: () => {},
afterNavigate: () => {},
goto: vi.fn(),
invalidate: vi.fn(),
invalidateAll: vi.fn(),
preloadCode: vi.fn(),
preloadData: vi.fn(),
pushState: vi.fn(),
replaceState: vi.fn(),
disableScrollHandling: vi.fn(),
onNavigate: () => () => {}
}));
vi.mock('$lib/shared/cookies', () => ({
csrfFetch: vi.fn()
}));
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
import { csrfFetch } from '$lib/shared/cookies';
import { goto } from '$app/navigation';
import type { components } from '$lib/generated/api';
const { default: GeschichtePage } = await import('./+page.svelte');
afterEach(cleanup);
beforeEach(() => {
vi.clearAllMocks();
});
const baseGeschichte = (overrides: Record<string, unknown> = {}) => ({
type GeschichteView = components['schemas']['GeschichteView'];
const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView => ({
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923 fuhr Helene...</p>',
publishedAt: '2026-04-15T10:00:00Z' as string | null,
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as {
firstName?: string;
lastName?: string;
email: string;
} | null,
persons: [] as { id: string; displayName: string }[],
documents: [] as { id: string; title: string; documentDate?: string | null }[],
type: 'STORY',
status: 'PUBLISHED',
author: { id: 'u1', displayName: 'Anna Schmidt' },
persons: [],
items: [],
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
publishedAt: '2026-04-15T10:00:00Z',
...overrides
});
@@ -55,9 +81,7 @@ describe('geschichten/[id] page', () => {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichte: baseGeschichte({
author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' }
})
geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } })
})
}
});
@@ -65,10 +89,10 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByText(/fallback@example.com/)).toBeVisible();
});
it('renders an empty author when author is null', async () => {
it('renders an empty author when author is absent', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) }
props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) }
});
await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible();
@@ -86,7 +110,9 @@ describe('geschichten/[id] page', () => {
it('omits the publishedAt suffix when publishedAt is null', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) }
props: {
data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) })
}
});
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
@@ -108,8 +134,8 @@ describe('geschichten/[id] page', () => {
data: baseData({
geschichte: baseGeschichte({
persons: [
{ id: 'p1', displayName: 'Helene Schmidt' },
{ id: 'p2', displayName: 'Karl Müller' }
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
]
})
})
@@ -130,20 +156,28 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
});
it('renders the documents section when there are linked documents', async () => {
it('renders the documents section when there are linked journey items', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichte: baseGeschichte({
documents: [{ id: 'd1', title: 'Brief 1923', documentDate: '1923-04-15' }]
items: [
{
id: 'item1',
position: 0,
document: { id: 'd1', title: 'Brief 1923', datePrecision: 'FULL' },
note: 'Brief aus 1923'
}
]
})
})
}
});
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
await expect.element(page.getByText('Brief 1923')).toBeVisible();
await expect.element(page.getByText('Dokument öffnen')).toBeVisible();
await expect.element(page.getByText('Brief aus 1923')).toBeVisible();
});
it('renders edit and delete actions when canBlogWrite is true', async () => {
@@ -167,4 +201,77 @@ describe('geschichten/[id] page', () => {
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
});
it('STORY with items:[] renders rich-text body and no empty-state message', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: { data: baseData({ geschichte: baseGeschichte({ type: 'STORY', items: [] }) }) }
});
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument();
});
it('type:undefined + non-empty body renders StoryReader and no empty-state', async () => {
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
props: {
data: baseData({
geschichte: baseGeschichte({
type: undefined as unknown as 'STORY' | 'JOURNEY',
items: []
})
})
}
});
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument();
});
it('delete success: navigates to /geschichten after confirmed DELETE returns ok', async () => {
vi.mocked(csrfFetch).mockResolvedValue(new Response(null, { status: 200 }));
const confirmService = createConfirmService();
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, confirmService]]),
props: { data: baseData({ canBlogWrite: true }) }
});
// Trigger delete — opens confirm dialog
const deleteBtn = page.getByRole('button', { name: /löschen/i });
await userEvent.click(deleteBtn);
// Settle the confirmation dialog
confirmService.settle(true);
// Wait for the async delete to complete, then check goto was called
await vi.waitFor(() => {
expect(vi.mocked(goto)).toHaveBeenCalledWith('/geschichten');
});
});
it('delete failure: shows error message when DELETE returns non-ok', async () => {
vi.mocked(csrfFetch).mockResolvedValue(
new Response(JSON.stringify({ code: 'FORBIDDEN' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
})
);
const confirmService = createConfirmService();
render(GeschichtePage, {
context: new Map([[CONFIRM_KEY, confirmService]]),
props: { data: baseData({ canBlogWrite: true }) }
});
// Trigger delete — opens confirm dialog
const deleteBtn = page.getByRole('button', { name: /löschen/i });
await userEvent.click(deleteBtn);
// Settle the confirmation dialog
confirmService.settle(true);
// Wait for the error to appear inline
await expect.element(page.getByRole('alert')).toBeVisible();
expect(vi.mocked(goto)).not.toHaveBeenCalled();
});
});

View File

@@ -10,24 +10,21 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => {
const api = createApiClient(fetch);
const personId = url.searchParams.get('personId');
const documentId = url.searchParams.get('documentId');
const [personResult, documentResult] = await Promise.all([
personId
? api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
: Promise.resolve(null),
documentId
? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } })
: Promise.resolve(null)
]);
const personResult = personId
? await api.GET('/api/persons/{id}', { params: { path: { id: personId } } })
: null;
// Silently ignore 404/403 to avoid leaking entity existence on unknown IDs.
const initialPersons =
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
const initialDocuments =
documentResult && documentResult.response.ok && documentResult.data
? [documentResult.data]
: [];
return { initialPersons, initialDocuments };
// Validate ?type against the known union — prevents unexpected strings from reaching the API.
// Security note: strict equality rejects encoded variants (e.g. STORY%00JOURNEY) and
// only the FIRST value is returned by searchParams.get() on repeated params.
const rawType = url.searchParams.get('type');
const selectedType: 'STORY' | 'JOURNEY' | null =
rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null;
return { initialPersons, selectedType };
};

View File

@@ -1,43 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
import TypeSelector from './TypeSelector.svelte';
import StoryCreate from './StoryCreate.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let submitting = $state(false);
let errorMessage: string | null = $state(null);
async function handleSubmit(payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
documentIds: string[];
}) {
submitting = true;
errorMessage = null;
try {
const res = await csrfFetch('/api/geschichten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code);
return;
}
const created = await res.json();
goto(`/geschichten/${created.id}`);
} finally {
submitting = false;
}
}
</script>
<div class="mx-auto max-w-5xl px-4 py-8">
@@ -47,19 +16,16 @@ async function handleSubmit(payload: {
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.geschichten_new_button()}</h1>
{#if errorMessage}
<div
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
role="alert"
>
{errorMessage}
{#if data.selectedType === 'STORY'}
<StoryCreate initialPersons={data.initialPersons} />
{:else if data.selectedType === 'JOURNEY'}
<div data-testid="journey-placeholder">
<p class="mb-4 font-sans text-base text-ink-2">{m.journey_placeholder_heading()}</p>
<a href="/geschichten/new" class="font-sans text-sm text-ink-3 underline hover:text-ink">
{m.journey_placeholder_back()}
</a>
</div>
{:else}
<TypeSelector onweiter={(type) => goto(`/geschichten/new?type=${type}`)} />
{/if}
<GeschichteEditor
initialPersons={data.initialPersons}
initialDocuments={data.initialDocuments}
onSubmit={handleSubmit}
submitting={submitting}
/>
</div>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { goto } from '$app/navigation';
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
import { getErrorMessage } from '$lib/shared/errors';
import { csrfFetch } from '$lib/shared/cookies';
import type { components } from '$lib/generated/api';
interface Props {
initialPersons: components['schemas']['Person'][];
}
let { initialPersons }: Props = $props();
let submitting = $state(false);
let errorMessage: string | null = $state(null);
async function handleSubmit(payload: {
title: string;
body: string;
status: 'DRAFT' | 'PUBLISHED';
personIds: string[];
}) {
submitting = true;
errorMessage = null;
try {
const res = await csrfFetch('/api/geschichten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, type: 'STORY' })
});
if (!res.ok) {
const code = (await res.json().catch(() => ({})))?.code;
errorMessage = getErrorMessage(code);
return;
}
const created = await res.json();
goto(`/geschichten/${created.id}`);
} finally {
submitting = false;
}
}
</script>
{#if errorMessage}
<div class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger" role="alert">
{errorMessage}
</div>
{/if}
<GeschichteEditor initialPersons={initialPersons} onSubmit={handleSubmit} submitting={submitting} />

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
type GeschichteType = 'STORY' | 'JOURNEY';
const TYPES: GeschichteType[] = ['STORY', 'JOURNEY'];
interface Props {
onweiter: (type: GeschichteType) => void;
}
let { onweiter }: Props = $props();
let selected = $state<GeschichteType | null>(null);
let announcement = $state('');
// Roving-tabindex holder: falls back to the first card so keyboard nav can start
// even when nothing is selected (all cards at tabindex=-1 would be a keyboard dead-spot).
const rovingFocusType = $derived(selected ?? TYPES[0]);
function select(type: GeschichteType) {
selected = type;
announcement = '';
}
function handleWeiter() {
if (!selected) {
announcement = m.journey_selector_aria_live_hint();
return;
}
onweiter(selected);
}
const titles: Record<GeschichteType, () => string> = {
STORY: m.journey_selector_story_title,
JOURNEY: m.journey_selector_journey_title
};
const descs: Record<GeschichteType, () => string> = {
STORY: m.journey_selector_story_desc,
JOURNEY: m.journey_selector_journey_desc
};
</script>
<div>
<p id="type-selector-label" class="mb-4 font-sans text-base font-medium text-ink">
{m.journey_selector_question()}
</p>
<div
role="radiogroup"
aria-labelledby="type-selector-label"
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
use:radioGroupNav={(v) => {
if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType);
}}
>
{#each TYPES as type (type)}
<button
type="button"
role="radio"
value={type}
aria-checked={selected === type}
tabindex={type === rovingFocusType ? 0 : -1}
onclick={() => select(type)}
class="min-h-[64px] cursor-pointer rounded border px-4 py-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected === type
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-surface text-ink hover:border-primary/50'}"
>
<span class="block font-sans text-sm font-bold">{titles[type]()}</span>
<span class="mt-1 block font-sans text-xs text-current opacity-70">{descs[type]()}</span>
</button>
{/each}
</div>
<div aria-live="polite" aria-atomic="true" class="sr-only">{announcement}</div>
{#if !selected}
<p id="type-hint" class="mt-3 font-sans text-xs text-ink-3" aria-hidden="true">
{m.journey_selector_aria_live_hint()}
</p>
{/if}
<button
type="button"
aria-disabled={selected == null ? 'true' : 'false'}
aria-describedby={selected == null ? 'type-hint' : undefined}
tabindex="0"
onclick={handleWeiter}
class="mt-6 inline-flex h-11 items-center rounded px-6 font-sans text-sm font-medium transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected == null
? 'cursor-not-allowed bg-primary text-primary-fg opacity-50'
: 'bg-primary text-primary-fg hover:opacity-90'}"
>
{m.journey_selector_next_btn()}
</button>
</div>

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
const { default: TypeSelector } = await import('./TypeSelector.svelte');
afterEach(cleanup);
describe('TypeSelector', () => {
it('renders both type cards', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
await expect.element(page.getByRole('radio', { name: /Geschichte/i })).toBeVisible();
await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible();
});
it('radiogroup is correctly labelled', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const group = document.querySelector('[role="radiogroup"]');
const labelledBy = group?.getAttribute('aria-labelledby');
const labelEl = labelledBy ? document.getElementById(labelledBy) : null;
expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0);
});
it('Weiter button has aria-disabled=true when nothing is selected', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
expect(weiter?.getAttribute('aria-disabled')).toBe('true');
});
it('no card is aria-checked when nothing is selected', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const radios = Array.from(document.querySelectorAll('[role="radio"]'));
const anyChecked = radios.some((r) => r.getAttribute('aria-checked') === 'true');
expect(anyChecked).toBe(false);
});
it('with no selection: first card has tabindex=0, second has tabindex=-1', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const radios = Array.from(document.querySelectorAll('[role="radio"]'));
expect(radios[0]?.getAttribute('tabindex')).toBe('0');
expect(radios[1]?.getAttribute('tabindex')).toBe('-1');
});
it('clicking STORY card sets aria-checked=true and enables Weiter', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
await userEvent.click(storyCard);
await expect.element(storyCard).toHaveAttribute('aria-checked', 'true');
const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
expect(weiter?.getAttribute('aria-disabled')).toBe('false');
});
it('clicking JOURNEY card sets aria-checked=true', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
await userEvent.click(journeyCard);
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
});
it('clicking Weiter after selection calls onweiter with the selected type', async () => {
const onweiter = vi.fn();
render(TypeSelector, { props: { onweiter } });
await userEvent.click(page.getByRole('radio', { name: /Geschichte/i }));
const weiter = page.getByRole('button', { name: /Weiter/i });
await userEvent.click(weiter);
expect(onweiter).toHaveBeenCalledWith('STORY');
});
it('clicking Weiter without selection does NOT call onweiter', async () => {
const onweiter = vi.fn();
render(TypeSelector, { props: { onweiter } });
// aria-disabled="true" prevents Playwright actionability — dispatch via DOM to test handler behaviour
const weiter = document.querySelector<HTMLButtonElement>('button[aria-disabled="true"]');
weiter?.click();
expect(onweiter).not.toHaveBeenCalled();
});
it('instructional text is visible when no type is selected', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
await expect.element(page.getByText(/Bitte wähle einen Typ/i)).toBeVisible();
});
it('ArrowRight moves focus and selection from STORY to JOURNEY', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator
await userEvent.keyboard('{ArrowRight}');
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
await expect.element(storyCard).toHaveAttribute('aria-checked', 'false');
});
it('ArrowLeft wraps from STORY back to JOURNEY', async () => {
render(TypeSelector, { props: { onweiter: vi.fn() } });
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator
await userEvent.keyboard('{ArrowLeft}');
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
});
});

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { API_INTERNAL_URL: 'http://backend:8080' }
}));
vi.mock('$lib/shared/api.server', () => ({
createApiClient: vi.fn(() => ({
GET: vi.fn().mockResolvedValue({ response: { ok: false }, data: null })
}))
}));
import { load } from './+page.server';
function makeEvent(search: string, canBlogWrite = true) {
return {
url: new URL(`http://localhost/geschichten/new${search}`),
request: new Request(`http://localhost/geschichten/new${search}`),
fetch: vi.fn(),
parent: vi.fn().mockResolvedValue({ canBlogWrite })
} as never;
}
describe('geschichten/new load — selectedType validation (security regression)', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns selectedType: STORY for ?type=STORY', async () => {
const result = await load(makeEvent('?type=STORY'));
expect(result.selectedType).toBe('STORY');
});
it('returns selectedType: JOURNEY for ?type=JOURNEY', async () => {
const result = await load(makeEvent('?type=JOURNEY'));
expect(result.selectedType).toBe('JOURNEY');
});
it('returns selectedType: null when ?type param is absent', async () => {
const result = await load(makeEvent(''));
expect(result.selectedType).toBeNull();
});
it('returns selectedType: null for invalid ?type param (security regression)', async () => {
const result = await load(makeEvent('?type=ADMIN'));
expect(result.selectedType).toBeNull();
});
it('returns selectedType: null for ?type=STORY%00JOURNEY (null-byte encoded — strict equality rejects it)', async () => {
// Strict equality rejects encoded variants; .includes/.startsWith would not.
const result = await load(makeEvent('?type=STORY%00JOURNEY'));
expect(result.selectedType).toBeNull();
});
it('returns selectedType: STORY for repeated ?type=STORY&type=JOURNEY (first-value semantics — intentional)', async () => {
// url.searchParams.get() returns the first value; this is intentional and documented.
const result = await load(makeEvent('?type=STORY&type=JOURNEY'));
expect(result.selectedType).toBe('STORY');
});
it('returns BOTH selectedType: STORY AND initialPersons when ?type=STORY&personId=p1 (no coupling)', async () => {
const { createApiClient } = await import('$lib/shared/api.server');
vi.mocked(createApiClient).mockReturnValue({
GET: vi
.fn()
.mockResolvedValue({ response: { ok: true }, data: { id: 'p1', displayName: 'Anna' } })
} as never);
const result = await load(makeEvent('?type=STORY&personId=p1'));
expect(result.selectedType).toBe('STORY');
expect(result.initialPersons).toHaveLength(1);
});
it('redirects non-BLOG_WRITE users to /geschichten', async () => {
await expect(load(makeEvent('', false))).rejects.toMatchObject({ location: '/geschichten' });
});
});

View File

@@ -20,51 +20,88 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte');
afterEach(cleanup);
const baseData = {
const baseData = (overrides: Record<string, unknown> = {}) => ({
initialPersons: [] as { id: string; displayName: string }[],
initialDocuments: [] as { id: string; title: string }[]
};
selectedType: 'STORY' as 'STORY' | 'JOURNEY' | null,
...overrides
});
describe('geschichten/new page', () => {
it('renders the page heading', async () => {
render(GeschichtenNewPage, { props: { data: baseData } });
render(GeschichtenNewPage, { props: { data: baseData() } });
await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible();
});
it('renders a button (BackButton component)', async () => {
render(GeschichtenNewPage, { props: { data: baseData } });
render(GeschichtenNewPage, { props: { data: baseData() } });
const buttons = document.querySelectorAll('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('does not render an error banner by default', async () => {
render(GeschichtenNewPage, { props: { data: baseData } });
render(GeschichtenNewPage, { props: { data: baseData() } });
expect(document.querySelector('[role="alert"]')).toBeNull();
});
it('renders the GeschichteEditor child component', async () => {
render(GeschichtenNewPage, { props: { data: baseData } });
it('renders the GeschichteEditor when selectedType is STORY', async () => {
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'STORY' }) } });
// Editor renders inputs/textarea — verify at least one form input is present
const inputs = document.querySelectorAll('input, textarea');
expect(inputs.length).toBeGreaterThan(0);
});
it('passes initialPersons and initialDocuments through to the editor', async () => {
it('passes initialPersons through to the editor', async () => {
render(GeschichtenNewPage, {
props: {
data: {
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }],
initialDocuments: [{ id: 'd1', title: 'Brief 1923' }]
}
data: baseData({
selectedType: 'STORY',
initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }]
})
}
});
// Both should appear somewhere in the rendered editor
await expect.element(page.getByText('Anna Schmidt')).toBeVisible();
await expect.element(page.getByText('Brief 1923')).toBeVisible();
});
it('shows TypeSelector radiogroup when selectedType is null', async () => {
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } });
await expect.element(page.getByRole('radiogroup')).toBeVisible();
});
it('shows JOURNEY placeholder when selectedType is JOURNEY', async () => {
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
const placeholder = document.querySelector('[data-testid="journey-placeholder"]');
expect(placeholder).not.toBeNull();
});
it('JOURNEY placeholder offers a return-to-selection link', async () => {
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } });
const backLink = page.getByRole('link', { name: /andere Auswahl/i });
await expect.element(backLink).toBeVisible();
await expect.element(backLink).toHaveAttribute('href', '/geschichten/new');
});
it('TypeSelector Weiter calls goto with ?type=STORY on STORY selection', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } });
// Select STORY
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
await storyCard.click();
// Click Weiter
const weiter = page.getByRole('button', { name: /Weiter/i });
await weiter.click();
expect(goto).toHaveBeenCalledWith('/geschichten/new?type=STORY');
});
});

View File

@@ -91,6 +91,19 @@ describe('geschichten page — multi-person filter chips', () => {
window.history.replaceState({}, '', originalHref);
});
it('JOURNEY row in the list shows the REISE badge (integration: page passes type through)', async () => {
render(Page, {
data: makeData({
geschichten: [
{ id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' }
] as PageData['geschichten']
})
});
const badge = document.querySelector('[data-testid="journey-badge"]');
expect(badge).not.toBeNull();
});
it('shows the "+ Person wählen" button even when filters are already active', async () => {
render(Page, {
data: makeData({

View File

@@ -188,8 +188,9 @@ describe('geschichten/+ page', () => {
// No "·" separator before date when no publishedAt
const titleHeading = document.querySelector('h2');
const card = titleHeading?.closest('li');
// The middle paragraph (author line) should not contain "·"
expect(card?.textContent).toContain('Anna Schmidt');
// "·" separator must be absent when there is no publishedAt date
expect(card?.textContent).not.toContain('·');
});
it('omits the body excerpt when body is empty', async () => {

View File

@@ -77,6 +77,11 @@
--color-warning: #b45309;
--color-warning-fg: #ffffff;
/* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */
--color-journey-tint: var(--c-journey-bg);
--color-journey: var(--c-journey-text);
--color-journey-border: var(--c-journey-border);
/* Static brand tokens (not themed) */
--color-brand-navy: var(--palette-navy);
--color-brand-mint: var(--palette-mint);
@@ -128,6 +133,12 @@
/* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */
--c-parchment: #faf8f1;
/* Journey / Lesereise — orange semantic tokens
Text #7A3F0E on bg #FEF0E6 ≈ 7.4:1 — WCAG AAA ✓ (text-xs requires 4.5:1 normal-text) */
--c-journey-bg: #fef0e6;
--c-journey-text: #7a3f0e;
--c-journey-border: #f0c99a;
/* Tag color tokens — decorative dot colors on tag chips */
--c-tag-sage: #5a8a6a;
--c-tag-sienna: #a0522d;
@@ -246,6 +257,12 @@
/* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for
visibility parity with the 8% light-mode token. Decorative carve-out. */
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
/* Journey / Lesereise — muted warm tint on dark navy; text #E8862A on
#3A2A1A ≈ 5.2:1 — WCAG AA ✓ (text-xs requires 4.5:1 normal-text) */
--c-journey-bg: #3a2a1a;
--c-journey-text: #e8862a;
--c-journey-border: #7a4a1e;
}
}
@@ -321,6 +338,11 @@
/* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */
--c-gutter-stripe: rgba(161, 220, 216, 0.14);
/* Journey / Lesereise — KEEP IN SYNC with the @media block above */
--c-journey-bg: #3a2a1a;
--c-journey-text: #e8862a;
--c-journey-border: #7a4a1e;
}
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */