feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s
This commit was merged in pull request #787.
This commit is contained in:
@@ -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` (document attachments / editorial notes shared by both subtypes — no application-level type guard). Two subtypes: `STORY` (prose + attached documents) and `JOURNEY` (ordered curated sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL). See ADR-037.
|
||||
|
||||
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
|
||||
|
||||
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
|
||||
@@ -124,6 +124,8 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `POSTGRES_PASSWORD` | DB password | `change-me` | YES | YES |
|
||||
| `POSTGRES_DB` | Database name | `family_archive_db` | YES | — |
|
||||
|
||||
> **PgBouncer pooling mode:** The `journey_items.position_seq` dedup constraint uses `DEFERRABLE INITIALLY DEFERRED`. This requires PgBouncer in **transaction-mode** (not statement-mode) pooling. Do not switch to statement-level pooling — deferred constraints only work within a single transaction session.
|
||||
|
||||
### MinIO container
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
|
||||
@@ -149,7 +149,20 @@ _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 attaching documents via `journey_items`) 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 document attachment or editorial note belonging to a `Geschichte` of either subtype. JOURNEY-type Geschichten use items for their ordered reading sequence; STORY-type Geschichten use items to attach referenced documents (no type guard is enforced at the application layer — both subtypes share this table). 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 Geschichte). 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`). See ADR-037.
|
||||
|
||||
**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.
|
||||
|
||||
**Interlude / Zwischentext** `[user-facing]` — an editorial paragraph inserted between document items in a *Lesereise*. An interlude is a `JourneyItem` with `document_id IS NULL` and a non-empty `note`; its content is a plain-text string stored in the `note` column (not `body` or `text`). Visually distinguished by `--color-interlude-bg/border/label` CSS tokens and a `ZWISCHENTEXT` label. Interludes cannot have their note removed (removing the interlude deletes the entire item).
|
||||
_Not to be confused with a document item's optional note_ — a document item's note is curator commentary attached to a linked letter; an interlude is standalone editorial prose with no backing document.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal 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.
|
||||
65
docs/adr/036-geschichte-responses-are-views-not-entities.md
Normal file
65
docs/adr/036-geschichte-responses-are-views-not-entities.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# ADR-036 — Geschichte responses are views assembled in-transaction, never entities
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-10
|
||||
**Issue:** #753 (JourneyEditor frontend), PR #792 review
|
||||
|
||||
## Context
|
||||
|
||||
The project convention (CLAUDE.md §DTOs) has been: *"Response types are the model
|
||||
entities themselves (no response DTOs)."* That convention assumed entities whose
|
||||
associations are either eager or initialized by the time Jackson serializes.
|
||||
|
||||
The lazy-fetch migration (ADR-022, `open-in-view: false`) broke that assumption:
|
||||
Jackson serializes **after** the service transaction has closed, so any lazy
|
||||
collection on a returned entity is a dead proxy. `Geschichte.items` (added with the
|
||||
Lesereisen data model, #750) made this concrete: every `PATCH /api/geschichten/{id}`
|
||||
(save draft, publish) failed with HTTP 500
|
||||
`LazyInitializationException: Geschichte.items … (no session)`.
|
||||
|
||||
Per-endpoint force-initialization (`g.getItems().size()` inside the transaction)
|
||||
worked for `getById()` but is a footgun: every new write method must remember the
|
||||
trick, the entity carries a warning comment nobody reads, and the raw entity also
|
||||
leaks the `author` `AppUser` graph (email, password hash, groups).
|
||||
|
||||
## Decision
|
||||
|
||||
In the **geschichte domain**, controllers never return entities. Every response is a
|
||||
purpose-built read model assembled **inside** the service transaction:
|
||||
|
||||
- `GET /api/geschichten` → `GeschichteSummary` (projection; never carries items;
|
||||
author exposes names only — never email)
|
||||
- `GET /api/geschichten/{id}` → `GeschichteView` (with `AuthorView`, `PersonView`,
|
||||
`JourneyItemView` items)
|
||||
- `POST /api/geschichten`, `PATCH /api/geschichten/{id}` → `GeschichteView`
|
||||
- JourneyItem endpoints → `JourneyItemView`
|
||||
|
||||
The invariant: **entities never cross the controller boundary in this domain.**
|
||||
A view is constructed while the Hibernate session is open, so serialization can
|
||||
never touch a lazy proxy, and the response shape is an explicit, security-reviewed
|
||||
contract.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **`@Transactional` on read/write methods + force-init (`getItems().size()`)** —
|
||||
fixes one endpoint at a time, silently regresses when the next write method is
|
||||
added, and still serializes the raw `AppUser` author graph.
|
||||
- **`open-in-view: true`** — re-opens the session during rendering; hides N+1
|
||||
queries and couples the HTTP layer to Hibernate session lifetime. Rejected
|
||||
already by ADR-022.
|
||||
- **Jackson `@JsonIgnore` on lazy fields** — loses the data the client needs
|
||||
(items ARE the journey) instead of loading it deliberately.
|
||||
|
||||
## Consequences
|
||||
|
||||
- CLAUDE.md §DTOs names the geschichte domain as the exception to the
|
||||
entities-as-responses convention. Other domains (document, person, tag) still
|
||||
return entities; they predate ADR-022's lazy collections on their hot paths and
|
||||
migrate opportunistically when they grow lazy collections of their own.
|
||||
- `npm run generate:api` must run after any view change — the generated
|
||||
`Geschichte` schema no longer exists; frontend consumers use
|
||||
`GeschichteView`/`GeschichteSummary`.
|
||||
- New geschichte endpoints must add a view (or extend an existing one), not return
|
||||
the entity. The regression guards are `GeschichteHttpTest`
|
||||
(`update_returns_200_and_serializes_items_open_in_view_false`) and
|
||||
`GeschichteListProjectionTest`.
|
||||
78
docs/adr/037-journey-items-serve-both-geschichte-subtypes.md
Normal file
78
docs/adr/037-journey-items-serve-both-geschichte-subtypes.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# ADR-037 — `journey_items` serves both STORY and JOURNEY Geschichte subtypes
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-11
|
||||
**Issue:** #795 (restore document management for STORY-type Geschichten), PR #804 review
|
||||
|
||||
## Context
|
||||
|
||||
V72 added the `journey_items` table as the backing store for Lesereisen (JOURNEY-type
|
||||
Geschichten). At the same time, the previous `geschichten_documents` join table was
|
||||
dropped (#753) and the restoration of a STORY-level document attachment mechanism was
|
||||
deferred to a future issue.
|
||||
|
||||
`JourneyItemService.append()` contained an application-level type guard that rejected
|
||||
`append()` calls on STORY-type Geschichten with `GESCHICHTE_TYPE_MISMATCH`. This guard
|
||||
was the only place where the STORY restriction was encoded — the database schema never
|
||||
enforced it (no CHECK constraint, no partial index on `type='JOURNEY'`).
|
||||
|
||||
When #795 restored document attachment for STORY-type Geschichten, the type guard was
|
||||
the only obstacle. Two implementation paths were considered:
|
||||
|
||||
1. Keep an allowlist (`if type not in (JOURNEY, STORY) throw ...`) — dead code today
|
||||
because `GeschichteType` is a two-constant enum; the branch can never be reached and
|
||||
would fail the JaCoCo branch-coverage gate.
|
||||
2. Delete the guard entirely — the schema never encoded the restriction; deleting dead
|
||||
application logic rather than replacing it with more dead logic.
|
||||
|
||||
Path 2 was chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
`journey_items` is the document-attachment mechanism for **both** `STORY` and `JOURNEY`
|
||||
subtypes. No application-level type guard governs which subtype may hold items. The only
|
||||
behavioral difference between the two subtypes' use of items is at the UI layer:
|
||||
|
||||
- JOURNEY: items form an ordered reading sequence rendered as a *Lesereise*.
|
||||
- STORY: items are a set of attached reference documents rendered as a sidebar panel.
|
||||
|
||||
Both subtypes share the same capacity cap (100 items), dedup index, position semantics,
|
||||
and DEFERRABLE constraint — enforced at the database layer, not re-implemented per subtype.
|
||||
|
||||
The `GESCHICHTE_TYPE_MISMATCH` error code was removed end-to-end (backend enum,
|
||||
frontend `ErrorCode` type + `getErrorMessage()` case, all three locale files).
|
||||
`GESCHICHTE_TYPE_IMMUTABLE` is unrelated and was left intact.
|
||||
|
||||
## Naming asymmetry (intentional)
|
||||
|
||||
The error codes `JOURNEY_AT_CAPACITY` and `JOURNEY_DOCUMENT_ALREADY_ADDED` carry
|
||||
journey-flavored names. Renaming them would ripple through `ErrorCode.java`, `errors.ts`,
|
||||
and three locale files for zero behavior change. `StoryDocumentPanel` remaps these two
|
||||
codes to story-worded user messages at the presentation layer — the asymmetry is a
|
||||
documented decision, not an accident.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **Separate `story_documents` join table for STORY** — creates two nearly-identical
|
||||
schemas for the same concept (document attachment with dedup and ordering), doubles the
|
||||
migration surface, and splits the capacity/dedup logic. Rejected as unnecessary
|
||||
duplication.
|
||||
- **Allowlist type guard (`if type not in (JOURNEY, STORY)`)** — unreachable dead code
|
||||
under a two-constant enum; fails the JaCoCo branch gate. Rejected.
|
||||
- **Per-subtype application validation** — the schema never encoded the restriction; an
|
||||
application-only rule with no schema backing is the weakest kind of invariant and was
|
||||
removed when the product decision reversed it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `JourneyItemService.append()` accepts items for any `Geschichte`, regardless of subtype.
|
||||
The 100-item cap and dedup constraint apply to all.
|
||||
- GLOSSARY.md and ARCHITECTURE.md updated to reflect that `JourneyItem` is not
|
||||
JOURNEY-specific.
|
||||
- The `l3-backend-3g-supporting.puml` C4 diagram updated: type-guard language removed,
|
||||
`geschQuerySvc` rel label reads "Checks Geschichte existence" (not "and type").
|
||||
- `StoryDocumentPanel.svelte` is the STORY-side consumer; `JourneyEditor.svelte` is the
|
||||
JOURNEY-side consumer. Neither is aware of the other.
|
||||
- Known pre-existing constraint conflict: `ON DELETE SET NULL` on `journey_items.document_id`
|
||||
combined with `chk_journey_item_not_empty` causes a DB-level 500 when a document linked
|
||||
via a note-less item is deleted. Pre-existing; tracked in follow-up issue.
|
||||
118
docs/adr/038-domain-event-driven-journey-item-cleanup.md
Normal file
118
docs/adr/038-domain-event-driven-journey-item-cleanup.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# ADR-038 — Domain event drives note-less journey-item cleanup on document delete
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-11
|
||||
**Issue:** #805 (P1 — deleting a document linked via a note-less journey_item 500s at DB constraint)
|
||||
|
||||
## Context
|
||||
|
||||
Two constraints in V72 encode contradictory rules for a journey item that has a
|
||||
`document_id` but no `note`:
|
||||
|
||||
- **`fk_journey_items_document` → `ON DELETE SET NULL`** — when a document is deleted,
|
||||
Postgres nulls out `document_id`.
|
||||
- **`chk_journey_item_not_empty`** — requires at least one of `document_id` or `note`
|
||||
to be non-null.
|
||||
|
||||
A note-less item (`document_id` set, `note IS NULL`) satisfies the CHECK while the
|
||||
document exists. Deleting the document causes Postgres to attempt `SET NULL`, which
|
||||
would leave both columns null — a direct CHECK violation. Postgres aborts the
|
||||
transaction with a 500 that bypasses `GlobalExceptionHandler`.
|
||||
|
||||
The natural fix — delete note-less items inside `DocumentService.deleteDocument` before
|
||||
`deleteById` runs — cannot call `JourneyItemService` directly: `JourneyItemService`
|
||||
already injects `DocumentService`, and Spring Framework 7 (used by Spring Boot 4)
|
||||
**fully prohibits constructor-injection cycles**. The application will not start if such
|
||||
a cycle is introduced.
|
||||
|
||||
## Decision
|
||||
|
||||
`DocumentService.deleteDocument` publishes a **`DocumentDeletingEvent`** (plain record,
|
||||
payload: `documentId` UUID only) via `ApplicationEventPublisher` **before**
|
||||
`documentRepository.deleteById`. A dedicated `@Component`
|
||||
`JourneyItemDocumentDeleteListener` in the `geschichte.journeyitem` package consumes
|
||||
this event and calls `journeyItemRepository.deleteNoteLessByDocumentId(documentId)`
|
||||
directly — bypassing `JourneyItemService` to avoid re-introducing the cycle and to
|
||||
suppress the per-item `JOURNEY_ITEM_REMOVED` audit emission (see audit decision below).
|
||||
|
||||
### Load-bearing listener-phase choice: plain `@EventListener`
|
||||
|
||||
The listener is annotated with `@EventListener` (not
|
||||
`@TransactionalEventListener(AFTER_COMMIT)`, not `@Async`). **This choice is
|
||||
load-bearing:**
|
||||
|
||||
- **`AFTER_COMMIT` would break the fix entirely.** `AFTER_COMMIT` fires *after* the
|
||||
surrounding transaction has committed. By that point, `documentRepository.deleteById`
|
||||
has already executed and Postgres has already tried `ON DELETE SET NULL` — the
|
||||
constraint violation fires before the listener ever runs.
|
||||
- **`@Async` would break rollback atomicity (AC-5).** An async listener runs on a
|
||||
separate thread in its own transaction. If `deleteDocument` subsequently rolls back
|
||||
(e.g. due to an unrelated failure), the listener's deletes are in a committed async
|
||||
transaction and cannot be undone.
|
||||
- **Plain `@EventListener` runs synchronously in the publisher's thread and
|
||||
transaction.** The listener's JPQL delete and the `deleteById` are a single atomic
|
||||
unit: if either fails, both roll back together.
|
||||
|
||||
### Repository method
|
||||
|
||||
```java
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
|
||||
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
|
||||
```
|
||||
|
||||
`i.document.id` (the real association path) is used instead of `i.documentId`: the
|
||||
transient `getDocumentId()` getter on `JourneyItem` makes Spring Data unable to resolve
|
||||
a derived query path (same trap documented at `JourneyItemRepository:33-44`).
|
||||
|
||||
`clearAutomatically = true` invalidates the L1 cache so subsequent reads in the same
|
||||
session do not return stale entities.
|
||||
|
||||
The predicate `(note IS NULL OR note = '')` covers the `note = ''` edge case that the
|
||||
service layer can never produce (normalizeNote converts blank strings to null), but that
|
||||
may exist via raw SQL inserts or legacy data. Whitespace-only notes (`note = ' '`)
|
||||
do not match and are preserved as note-carrying placeholders.
|
||||
|
||||
### Audit decision
|
||||
|
||||
The listener calls the repository directly rather than routing through
|
||||
`JourneyItemService.delete`. This deliberately bypasses the `JOURNEY_ITEM_REMOVED`
|
||||
audit emission: a document used in multiple journeys would otherwise produce N audit
|
||||
rows for a single user action. The `DOCUMENT_DELETED` entry written by `deleteDocument`
|
||||
is the sole audit record for the operation.
|
||||
|
||||
### Boundary: documents must not depend on journey
|
||||
|
||||
The event direction is `document → journey`, never the reverse. `DocumentService`
|
||||
publishes events it knows nothing about the consumers of; `JourneyItemService`'s
|
||||
dependency on `DocumentService` is unchanged and remains the only cross-domain
|
||||
reference. This direction is the prerequisite for the cycle constraint to hold.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **DB trigger on `journey_items`** — trigger logic is opaque to Java developers,
|
||||
invisible to code review, and not covered by the JPA test harness.
|
||||
- **RESTRICT instead of SET NULL** — breaks the existing note-carrying placeholder
|
||||
UX: deleting a document with a note-carrying journey item would 409 instead of
|
||||
preserving the item as a placeholder.
|
||||
- **Relax `chk_journey_item_not_empty`** — the constraint enforces a real invariant
|
||||
(every item must have at least document or note). Removing it would allow empty rows.
|
||||
- **`@Lazy` on the `JourneyItemService → DocumentService` injection** — Spring Boot 4 /
|
||||
Spring Framework 7 prohibits constructor-injection cycles regardless of `@Lazy`.
|
||||
- **Make `DocumentService` call `JourneyItemService`** — introduces the prohibited
|
||||
cycle. Rejected at design time.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **No schema change** — no new Flyway migration, no `db-orm.puml` /
|
||||
`db-relationships.puml` update.
|
||||
- This is the **first custom domain event** in the codebase. No prior
|
||||
`ApplicationEventPublisher` usage existed in `main/`. New cross-domain cleanup that
|
||||
cannot use direct service calls should follow this pattern.
|
||||
- All tests that delete documents and then assert journey-item state **must route
|
||||
through `DocumentService.deleteDocument`**, not `documentRepository.deleteById`.
|
||||
The existing `JourneyItemIntegrationTest` tests that covered the note-carrying
|
||||
placeholder UX have been updated accordingly.
|
||||
- The `DOCUMENT_DELETED` `AuditKind` was added as part of this fix to give AC-7's
|
||||
audit assertion a positive check (absence-only assertions pass vacuously if all
|
||||
auditing regresses).
|
||||
@@ -16,8 +16,11 @@ 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). Serves both STORY and JOURNEY subtypes.")
|
||||
Component(journeyListener, "JourneyItemDocumentDeleteListener", "Spring @EventListener", "Consumes DocumentDeletingEvent synchronously inside the delete transaction and removes note-less journey items before ON DELETE SET NULL fires, preventing a chk_journey_item_not_empty violation. See ADR-038.")
|
||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||
}
|
||||
|
||||
@@ -38,6 +41,12 @@ 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")
|
||||
Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
|
||||
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")
|
||||
Rel(documentSvc, journeyListener, "DocumentDeletingEvent", "in-process event")
|
||||
Rel(journeyListener, db, "Deletes note-less journey_items", "JDBC")
|
||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||
|
||||
@@ -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 (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{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}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "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")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@startuml db-orm
|
||||
' Schema source: Flyway V1–V69 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V69 (2026-05-27)
|
||||
' Schema source: Flyway V1–V72 (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
|
||||
@@ -357,8 +357,9 @@ package "Supporting" {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
title : VARCHAR(255) NOT NULL
|
||||
body : TEXT
|
||||
body : TEXT CHECK (JOURNEY: length <= 4000)
|
||||
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,16 @@ 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 CHECK (length <= 2000)
|
||||
==
|
||||
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||
UNIQUE (geschichte_id, document_id) WHERE document_id IS NOT NULL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +444,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
|
||||
|
||||
@@ -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,9 @@ 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)
|
||||
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
|
||||
note right of geschichten : CHECK length(body) <= 4000\nfor type = JOURNEY (V75)
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -421,16 +421,16 @@
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||
<tr><td>Page title</td><td>font-family:var(--font-display);font-size:24px;color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
|
||||
<tr><td>Editorial list card</td><td>bg-white shadow-sm border border-brand-sand rounded-sm</td><td>wraps alle Zeilen</td></tr>
|
||||
<tr><td>Editorial list card</td><td><s>bg-white shadow-sm border border-brand-sand rounded-sm</s> <em>(implementiert: bg-surface border-line — semantische Tokens für Dark Mode)</em></td><td>wraps alle Zeilen</td></tr>
|
||||
<tr class="grp"><td colspan="3">Listenzeile</td></tr>
|
||||
<tr><td>List row</td><td>flex gap-0 border-b border-brand-sand last:border-0 hover:bg-surface</td><td>min-h-[44px] auf Mobile</td></tr>
|
||||
<tr><td>Meta column</td><td>w-[88px] shrink-0 flex flex-col gap-1 p-3 border-r border-brand-sand</td><td>feste Breite</td></tr>
|
||||
<tr><td>Meta column</td><td>w-40 shrink-0 flex flex-col gap-1 p-3 border-r border-line-2</td><td>feste Breite — breit genug für text-sm Namen ohne Umbruch</td></tr>
|
||||
<tr><td>Author avatar</td><td>w-7 h-7 rounded-full text-[9px] font-bold text-white flex items-center justify-center</td><td>personAvatarColor(userId)</td></tr>
|
||||
<tr><td>Author name</td><td>font-sans text-xs font-semibold text-ink</td><td></td></tr>
|
||||
<tr><td>Date</td><td>font-sans text-xs text-ink-3</td><td>formatDate(publishedAt)</td></tr>
|
||||
<tr><td>Author name</td><td>font-sans text-sm font-semibold text-ink</td><td></td></tr>
|
||||
<tr><td>Date</td><td>font-sans text-sm text-ink-3</td><td>formatDate(publishedAt)</td></tr>
|
||||
<tr><td>Person chip</td><td>inline-flex items-center gap-1 rounded-full bg-surface border border-line px-2 py-0.5 text-[10px] font-medium text-ink</td><td>links zu /persons/[id]; optional</td></tr>
|
||||
<tr><td>Story title</td><td>font-serif text-[15px] text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr>
|
||||
<tr><td>Excerpt</td><td>font-sans text-xs text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr>
|
||||
<tr><td>Story title</td><td>font-serif text-lg text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr>
|
||||
<tr><td>Excerpt</td><td>font-sans text-sm text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr>
|
||||
<tr class="grp"><td colspan="3">Filter</td></tr>
|
||||
<tr><td>Filter pill (inaktiv)</td><td>rounded-full border border-line px-3 py-1 text-xs font-semibold text-ink-2 hover:bg-muted</td><td>aria-pressed="false"</td></tr>
|
||||
<tr><td>Filter pill (aktiv)</td><td>rounded-full bg-primary text-primary-fg px-3 py-1 text-xs font-semibold</td><td>aria-pressed="true"</td></tr>
|
||||
@@ -640,7 +640,8 @@
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Artikel-Container</td></tr>
|
||||
<tr><td>Article container</td><td>max-w-3xl mx-auto px-4 py-10</td><td>zentriert, volle Breite auf Mobile</td></tr>
|
||||
<tr><td>Article container</td><td>max-w-7xl mx-auto px-4 py-8; innere Lesespalte: max-w-3xl mx-auto</td><td>Seite so breit wie Dokumente/Personen; Textspalte bleibt lesbar zentriert</td></tr>
|
||||
<tr><td>Article sheet</td><td>rounded-sm border border-line bg-sheet shadow-sm px-5 py-6 sm:px-10 sm:py-10</td><td>Lesebogen-Panel zwischen Canvas und weißen Karten (Token --color-sheet); BackButton bleibt außerhalb</td></tr>
|
||||
<tr><td>Story title</td><td>font-family:var(--font-display);font-size:clamp(22px,4vw,32px);color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
|
||||
<tr><td>Back button</td><td><BackButton /> aus $lib/components/BackButton.svelte</td><td>history.back(); nicht <a href></td></tr>
|
||||
<tr class="grp"><td colspan="3">Metazeile</td></tr>
|
||||
@@ -657,7 +658,7 @@
|
||||
<tr><td>Doc reference card</td><td>flex gap-3 items-start bg-white border border-brand-sand rounded-sm p-3 hover:shadow-sm</td><td>links zu /documents/[id]</td></tr>
|
||||
<tr><td>Doc icon</td><td>w-9 h-9 bg-surface rounded flex items-center justify-center shrink-0</td><td>Dateisymbol SVG</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
||||
<tr><td>… Menü (Mobile)</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr>
|
||||
<tr><td>… Menü (Mobile)</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: Bearbeiten/Löschen bleiben inline in der Metazeile auf allen Breiten — kein BottomSheet)</em></td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr>
|
||||
<tr><td>Person chips (Mobile)</td><td>flex-wrap, volle Breite</td><td>kein horizontales Scrollen</td></tr>
|
||||
<tr><td>Doc cards (Mobile)</td><td>flex-col gap-2</td><td>stapeln vertikal</td></tr>
|
||||
</tbody>
|
||||
@@ -712,7 +713,7 @@
|
||||
<ul>
|
||||
<li>Die Schaltflächen „+ Neue Geschichte", „Bearbeiten" und „Löschen" werden nur gerendert, wenn <code>currentUser.permissions.includes('BLOG_WRITE')</code> wahr ist.</li>
|
||||
<li>Nicht nur ausblenden — Backend-Endpunkte für Schreib-/Löschoperationen sind ebenfalls durch <code>@RequirePermission(Permission.BLOG_WRITE)</code> geschützt.</li>
|
||||
<li>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</li>
|
||||
<li><s>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets, kein BottomSheet)</em></li>
|
||||
</ul>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
|
||||
@@ -500,7 +500,7 @@
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
|
||||
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: bg-orange-50 border-orange-200</td></tr>
|
||||
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: <del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> / <code>--color-interlude-border</code> CSS tokens</td></tr>
|
||||
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
|
||||
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
|
||||
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
|
||||
@@ -508,18 +508,18 @@
|
||||
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
|
||||
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Link</td><td>text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1</td><td>togglet Notiz-Textarea</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Link</td><td><del>text-xs font-semibold text-blue-600</del> → <code>text-xs text-ink-3 underline hover:text-accent</code></td><td>togglet Notiz-Textarea</td></tr>
|
||||
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
|
||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||
<tr><td>Interlude-Container</td><td>bg-orange-50 border-orange-200 (überschreibt Item-Container)</td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||
<tr><td>Label „Zwischentext"</td><td>text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1</td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||
<tr><td>Zwischentext-Textarea</td><td>w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400</td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||
<tr><td>Interlude-Container</td><td><del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> left-accent border via <code>--color-interlude-border</code></td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||
<tr><td>Label „Zwischentext"</td><td><del>text-orange-700</del> → <code>color: var(--color-interlude-label)</code></td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||
<tr><td>Zwischentext-Textarea</td><td><del>border-orange-200 focus:border-orange-400</del> → <code>border-line focus-visible:ring-focus-ring</code></td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
|
||||
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
|
||||
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
|
||||
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
|
||||
<tr><td>Bibliothek</td><td>@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)</td><td>kein neues Package ohne Absprache</td></tr>
|
||||
<tr><td>Bibliothek</td><td><del>@dnd-kit/core oder svelte-dnd-action</del> → <code>createBlockDragDrop<JourneyItemView></code> aus <code>$lib/document/transcription/useBlockDragDrop.svelte</code></td><td>kein externes Package; pointer-Events + data-drag-handle / data-block-wrapper Kontrakt</td></tr>
|
||||
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
|
||||
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
|
||||
</tbody>
|
||||
@@ -720,7 +720,7 @@
|
||||
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
|
||||
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
|
||||
<tr class="grp"><td colspan="3">Touch & Drag</td></tr>
|
||||
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr>
|
||||
<tr><td>Drag auf Mobile</td><td>Move-Up/Down Buttons statt Drag (44px touch targets)</td><td><del>dnd-kit unterstützt Touch nativ</del> → Pointer-Drag nur Desktop; Keyboard via Pfeil-Buttons</td></tr>
|
||||
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
|
||||
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Savebar</td></tr>
|
||||
@@ -779,7 +779,7 @@
|
||||
|
||||
<h3>Drag-to-Reorder</h3>
|
||||
<ul>
|
||||
<li>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist. Kein neues Package einführen ohne Absprache.</li>
|
||||
<li><del>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist.</del> → Implementiert mit <code>createBlockDragDrop<JourneyItemView></code> (kein externes Package).</li>
|
||||
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
|
||||
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
|
||||
</ul>
|
||||
|
||||
@@ -629,27 +629,28 @@
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
|
||||
<tr><td>Artikel-Container</td><td>max-w-3xl mx-auto px-4 py-8</td><td>gleich wie StoryReader</td></tr>
|
||||
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
|
||||
<tr><td>Artikel-Container</td><td>max-w-7xl mx-auto px-4 py-8; innere Lesespalte: max-w-3xl mx-auto</td><td>gleich wie StoryReader (R-2)</td></tr>
|
||||
<tr><td>Artikel-Sheet</td><td>rounded-sm border border-line bg-sheet shadow-sm px-5 py-6 sm:px-10 sm:py-10</td><td>Lesebogen-Panel zwischen Canvas und weißen Karten (Token --color-sheet), gleich wie Story (R-2); BackButton bleibt außerhalb</td></tr>
|
||||
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-journey-tint text-journey border border-journey-border mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
|
||||
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; <s>auf Mobile im ··· BottomSheet</s> <em>(implementiert: inline in der Metazeile auf allen Breiten)</em></td><td>gleich wie Story</td></tr>
|
||||
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
|
||||
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
|
||||
<tr><td>Intro (body)</td><td>font-serif text-lg text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
|
||||
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
|
||||
<tr><td>Dokumentkarte</td><td>bg-white border border-line rounded-sm p-3</td><td></td></tr>
|
||||
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
|
||||
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
|
||||
<tr><td>Dokumentkarte</td><td>bg-surface border border-line rounded-sm p-3</td><td></td></tr>
|
||||
<tr><td>Brieftitel</td><td>font-serif text-base text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-sm text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
|
||||
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-sm font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
|
||||
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
|
||||
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr>
|
||||
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-brand-mint bg-muted rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
|
||||
<tr><td>Annotations-Text</td><td>text-base italic text-ink-2 leading-relaxed</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
|
||||
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
|
||||
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-journey-border bg-journey-tint rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
|
||||
<tr><td>Interlude-Text</td><td>text-base italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
||||
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr>
|
||||
<tr><td>··· Menü</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: kein BottomSheet — Aktionen inline)</em></td><td>BLOG_WRITE; gleich wie Story</td></tr>
|
||||
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -710,7 +711,7 @@
|
||||
<h3>Berechtigungen</h3>
|
||||
<ul>
|
||||
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
|
||||
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li>
|
||||
<li><s>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets)</em></li>
|
||||
</ul>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
|
||||
Reference in New Issue
Block a user