Compare commits

...

6 Commits

Author SHA1 Message Date
b33d0eb850 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
2026-06-12 14:04:02 +02:00
Marcel
4bcf568ed4 Merge branch 'main' of ssh://git.raddatz.cloud:222/marcel/familienarchiv
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m22s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m41s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
nightly / deploy-staging (push) Successful in 2m50s
2026-06-08 16:27:41 +02:00
Marcel
ddb1ec4df8 docs(timeline): add Zeitstrahl visual specs (global Concept A, event editor)
Visual design specs for Milestone #14:
- zeitstrahl-global-concepts.html — A/B/C exploration of the global timeline
- zeitstrahl-final-spec.html — canonical Concept A (global + per-person Lebensweg)
- zeitstrahl-event-editor-spec.html — curator event editor + document quick-action

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:27:15 +02:00
d650b6c066 refactor(search): remove NLP/smart-search feature entirely (#772)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
## Summary

- Removes the NLP/smart-search feature completely — the feature was too unreliable and slow; users get better results with the regular search filters
- Deletes the entire backend `search/` package (NlSearchController, NlQueryParserService, NlpClient, NlSearchRateLimiter — 14 classes + 6 test classes)
- Deletes the `nlp-service/` Python microservice (FastAPI, rapidfuzz, DB-backed person matching)
- Removes all frontend NL search components: SmartModeToggle, SmartSearchStatus, InterpretationChipRow, DisambiguationPicker, chip-types, theme-chip-removal
- Strips smart-mode logic from SearchFilterBar and documents/+page.svelte
- Removes `SMART_SEARCH_UNAVAILABLE` / `SMART_SEARCH_RATE_LIMITED` error codes from backend, frontend types, and all three i18n files (de/en/es)
- Removes `nlp-service` container and `APP_NLP_BASE_URL` from both docker-compose files
- Removes Ollama/NLP Prometheus scrape job and Grafana dashboard
- Deletes ADRs 028 (×2), 034, 035

## Test plan

- [ ] Backend compiles: `cd backend && ./mvnw compile -q` → BUILD SUCCESS
- [ ] Frontend server tests pass: `cd frontend && npm run test -- --project=server`
- [ ] No NLP/smart-search references remain in source: `grep -r "SmartSearch\|NlSearch\|nlp-service\|SMART_SEARCH" backend/src frontend/src`
- [ ] `docker compose config` validates both compose files
- [ ] Search page loads, filter bar works, no smart-mode toggle visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #772
2026-06-08 10:57:00 +02:00
Marcel
e63eaadc33 docs(timeline): add Person date+precision migration as foundational issue
Replace Person birthYear/deathYear integers with birthDate/deathDate +
DatePrecision so known exact birthdays render precisely. Migration,
re-import preservation rule, and bounded blast radius captured; becomes
issue 1 the timeline's derived events depend on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:26:13 +02:00
Marcel
d4a25e34d8 docs(timeline): add family timeline (Zeitstrahl) design spec
Hand-curated, year-banded vertical timeline weaving derived person
life-events, curated personal/historical events, and date-placed
letters. Includes proposed sub-issue breakdown for a milestone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:18:55 +02:00
196 changed files with 13819 additions and 5279 deletions

View File

@@ -86,13 +86,13 @@ 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
├── person/ Person domain
│ └── relationship/ PersonRelationship sub-domain
├── search/ NL search domain — NlSearchController, NlQueryParserService, RestClientOllamaClient, NlSearchRateLimiter
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
├── tag/ Tag domain
└── user/ User domain — AppUser, UserGroup, UserService
@@ -106,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`
@@ -153,7 +155,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
### DTOs
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs)**except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen.
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
@@ -161,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `SMART_SEARCH_UNAVAILABLE` (HTTP 503 — Ollama inference service offline or timed out); `SMART_SEARCH_RATE_LIMITED` (HTTP 429 — user exceeded 5 NL search requests per minute).
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
### Security / Permissions
@@ -269,7 +271,7 @@ Back button pattern — use the shared `<BackButton>` component from `$lib/share
→ See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `SMART_SEARCH_UNAVAILABLE` (HTTP 503 — Ollama inference service offline or timed out); `SMART_SEARCH_RATE_LIMITED` (HTTP 429 — user exceeded 5 NL search requests per minute).
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
---

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,30 @@ public enum AuditKind {
ADMIN_FORCE_LOGOUT,
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
LOGIN_RATE_LIMITED;
LOGIN_RATE_LIMITED,
// --- Documents ---
/** Payload: none — the deleted document's id is carried in the documentId column */
DOCUMENT_DELETED,
// --- 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

@@ -168,8 +168,8 @@ public class DocumentController {
@DeleteMapping("/{id}")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
documentService.deleteDocument(id);
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
documentService.deleteDocument(id, requireUserId(authentication));
return ResponseEntity.noContent().build();
}

View File

@@ -0,0 +1,11 @@
package org.raddatz.familienarchiv.document;
import java.util.UUID;
/**
* Published by DocumentService.deleteDocument inside its @Transactional boundary,
* before documentRepository.deleteById fires. Listeners run synchronously in the
* publisher's thread and transaction via plain @EventListener — this is load-bearing:
* see ADR-038.
*/
public record DocumentDeletingEvent(UUID documentId) {}

View File

@@ -36,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
@EntityGraph("Document.list")
Page<Document> findAll(Pageable pageable);
// Loader for the relevance fast path: list-item enrichment reads tags after the
// repository call returns, so the fetch shape must match the spec-based findAll
// overloads above. Plain findAllById carries no entity graph and must not feed
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
@EntityGraph("Document.list")
List<Document> findByIdIn(Collection<UUID> ids);
// Findet ein Dokument anhand des ursprünglichen Dateinamens
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
Optional<Document> findByOriginalFilename(String originalFilename);

View File

@@ -28,6 +28,7 @@ import org.raddatz.familienarchiv.ocr.TrainingLabel;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
@@ -80,6 +81,7 @@ public class DocumentService {
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AuditLogQueryService auditLogQueryService;
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
private final ApplicationEventPublisher eventPublisher;
public record StoreResult(Document document, boolean isNew) {}
@@ -851,14 +853,14 @@ public class DocumentService {
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
// Preserve ts_rank order from SQL across the JPA findAllById call.
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
Map<UUID, Integer> rankMap = new HashMap<>();
List<UUID> pageIds = new ArrayList<>();
for (int i = 0; i < ftsPage.hits().size(); i++) {
rankMap.put(ftsPage.hits().get(i).id(), i);
pageIds.add(ftsPage.hits().get(i).id());
}
List<Document> docs = documentRepository.findAllById(pageIds).stream()
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
.toList();
return buildResultPaged(docs, text, pageable, ftsPage.total());
@@ -1006,6 +1008,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
@@ -1075,11 +1099,13 @@ public class DocumentService {
}
@Transactional
public void deleteDocument(UUID id) {
public void deleteDocument(UUID id, UUID actorId) {
if (!documentRepository.existsById(id)) {
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
}
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
documentRepository.deleteById(id);
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
}
@Transactional

View File

@@ -122,6 +122,22 @@ 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 document is already present in this journey — duplicate items are not allowed. 409 */
JOURNEY_DOCUMENT_ALREADY_ADDED,
/** The type of an existing Geschichte cannot be changed via PATCH. 409 */
GESCHICHTE_TYPE_IMMUTABLE,
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
JOURNEY_NOTE_TOO_LONG,
/** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */
GESCHICHTE_TITLE_TOO_LONG,
/** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */
GESCHICHTE_INTRO_TOO_LONG,
// --- Tags ---
/** A tag with the given ID does not exist. 404 */
@@ -135,12 +151,6 @@ public enum ErrorCode {
/** The merge target is a descendant of the source tag. 400 */
TAG_MERGE_INVALID_TARGET,
// --- NL Search ---
/** Ollama is unreachable or timed out. 503 */
SMART_SEARCH_UNAVAILABLE,
/** NL search rate limit exceeded (5 requests per user per minute). 429 */
SMART_SEARCH_RATE_LIMITED,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,

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,15 @@
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 io.swagger.v3.oas.annotations.Parameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -14,6 +17,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,12 +32,17 @@ import java.util.UUID;
public class GeschichteController {
private final GeschichteService geschichteService;
private final JourneyItemService journeyItemService;
@GetMapping
public List<Geschichte> list(
public List<GeschichteSummary> list(
@Parameter(description = "Filter by status. Callers without BLOG_WRITE always receive PUBLISHED results regardless of the value passed. Callers with BLOG_WRITE requesting DRAFT receive only their own unpublished stories.")
@RequestParam(required = false) GeschichteStatus status,
@Parameter(description = "AND-filter: story must include all supplied person IDs.")
@RequestParam(name = "personId", required = false) List<UUID> personIds,
@Parameter(description = "Filter to stories containing this document.")
@RequestParam(required = false) UUID documentId,
@Parameter(description = "Maximum results to return. Values ≤ 0 default to 50. Clamped at 200.")
@RequestParam(required = false, defaultValue = "50") int limit) {
return geschichteService.list(
status,
@@ -43,20 +52,20 @@ public class GeschichteController {
}
@GetMapping("/{id}")
public Geschichte getById(@PathVariable UUID id) {
return geschichteService.getById(id);
public GeschichteView getById(@PathVariable UUID id) {
return geschichteService.getView(id);
}
@PostMapping
@RequirePermission(Permission.BLOG_WRITE)
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
Geschichte created = geschichteService.create(dto);
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
GeschichteView created = geschichteService.create(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PatchMapping("/{id}")
@RequirePermission(Permission.BLOG_WRITE)
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
return geschichteService.update(id, dto);
}
@@ -66,4 +75,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,47 @@
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.updatedAt AS updatedAt, 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)
AND (:documentId IS NULL OR
EXISTS (SELECT 1 FROM JourneyItem ji
WHERE ji.geschichte = g AND ji.document.id = :documentId))
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,
@Param("documentId") UUID documentId);
}

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
@@ -54,12 +50,26 @@ public class GeschichteService {
private static final int DEFAULT_LIMIT = 50;
private static final int MAX_LIMIT = 200;
/** Sentinel used when {@code personIds} is empty to avoid invalid empty IN() SQL. */
private static final UUID NIL_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
// turns what would be a DB-level 500 into a friendly 400.
static final int MAX_TITLE_LENGTH = 255;
// JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
// same three-layer bound as journey notes: frontend maxlength, this check, and
// the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
// unbounded on purpose.
static final int MAX_INTRO_LENGTH = 4000;
// ─── Read API ────────────────────────────────────────────────────────────
public long countPublished() {
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
}
// readOnly = true: lazy collections resolve within the same tx when called from getView()
@Transactional(readOnly = true)
public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
@@ -72,24 +82,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, UUID documentId, 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(NIL_UUID)
: personIds;
long personCount = (personIds == null) ? 0 : personIds.size();
return geschichteRepository
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
.stream()
.limit(safeLimit)
.toList();
@@ -97,46 +140,57 @@ public class GeschichteService {
// ─── Write API ───────────────────────────────────────────────────────────
// Write methods return GeschichteView, never the entity: Jackson serializes after
// the transaction closed, where the lazy items collection is a dead proxy.
// The view is assembled in-transaction, so no force-init tricks are needed.
@Transactional
public Geschichte create(GeschichteUpdateDTO dto) {
public GeschichteView create(GeschichteUpdateDTO dto) {
requireTitle(dto.getTitle());
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
Geschichte g = Geschichte.builder()
.title(dto.getTitle().trim())
.body(sanitize(dto.getBody()))
.body(bodyForType(type, dto.getBody()))
.status(GeschichteStatus.DRAFT)
.type(type)
.author(currentUser())
.persons(resolvePersons(dto.getPersonIds()))
.documents(resolveDocuments(dto.getDocumentIds()))
.build();
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
g.setStatus(GeschichteStatus.PUBLISHED);
g.setPublishedAt(LocalDateTime.now());
}
return geschichteRepository.save(g);
Geschichte saved = geschichteRepository.save(g);
// A freshly created Geschichte has no items by construction — items are only
// addable via the separate /items endpoints. Revisit if a create DTO ever
// accepts initial items.
return toView(saved, List.of());
}
@Transactional
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
if (dto.getType() != null && dto.getType() != g.getType()) {
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
"The type of a Geschichte cannot be changed after creation");
}
if (dto.getTitle() != null) {
requireTitle(dto.getTitle());
g.setTitle(dto.getTitle().trim());
}
if (dto.getBody() != null) {
g.setBody(sanitize(dto.getBody()));
g.setBody(bodyForType(g.getType(), dto.getBody()));
}
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());
}
return geschichteRepository.save(g);
Geschichte saved = geschichteRepository.save(g);
return toView(saved, journeyItemService.getItems(id));
}
@Transactional
@@ -164,6 +218,27 @@ public class GeschichteService {
throw DomainException.badRequest(
ErrorCode.VALIDATION_ERROR, "Title is required");
}
if (title.trim().length() > MAX_TITLE_LENGTH) {
throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
}
}
/**
* STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
* JOURNEY intros are plain text: the reader renders them via Svelte text
* interpolation (never {@code {@html}}), so entity-encoding them here would
* corrupt content ("&" → "&amp;") and re-encode on every editor round-trip.
*/
private String bodyForType(GeschichteType type, String body) {
if (type != GeschichteType.JOURNEY) {
return sanitize(body);
}
if (body != null && body.length() > MAX_INTRO_LENGTH) {
throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
"Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
}
return body;
}
private String sanitize(String body) {
@@ -176,15 +251,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,45 @@
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();
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime getUpdatedAt();
String getBody();
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
interface AuthorSummary {
String getFirstName();
String getLastName();
}
}

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;
@@ -16,6 +15,6 @@ public class GeschichteUpdateDTO {
private String title;
private String body;
private GeschichteStatus status;
private GeschichteType type;
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,54 @@
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;
/**
* Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output.
*
* <p>CWE-79 tripwire: stored verbatim; only Svelte {note} interpolation is auto-safe.</p>
*/
@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,30 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.document.DocumentDeletingEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
class JourneyItemDocumentDeleteListener {
private final JourneyItemRepository journeyItemRepository;
/**
* Plain @EventListener — runs synchronously in the publisher's thread and transaction.
* Load-bearing choice: AFTER_COMMIT would fire after the FK ON DELETE SET NULL has
* already 500'd; @Async would run outside the delete transaction (breaks AC-5 rollback).
* See ADR-038. DocumentService cannot call JourneyItemService directly because
* Spring Framework 7 prohibits the resulting constructor-injection cycle.
*/
@EventListener
void onDocumentDeleting(DocumentDeletingEvent event) {
int deleted = journeyItemRepository.deleteNoteLessByDocumentId(event.documentId());
if (deleted > 0) {
log.warn("Cascade-deleted {} note-less journey item(s) for document {}", deleted, event.documentId());
}
}
}

View File

@@ -0,0 +1,69 @@
package org.raddatz.familienarchiv.geschichte.journeyitem;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
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);
/**
* Dedup guard: true when the document is already linked to this journey.
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
* getter on JourneyItem makes Spring Data resolve the derived path as a
* direct {@code documentId} attribute, which Hibernate cannot map.
*/
@Query("""
SELECT COUNT(i) > 0 FROM JourneyItem i
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
""")
boolean existsByGeschichteIdAndDocumentId(
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
/**
* Deletes note-less items (note IS NULL or note = '') linked to the given document.
* Used by JourneyItemDocumentDeleteListener before the document row is removed, so
* the FK ON DELETE SET NULL never fires on rows that would violate chk_journey_item_not_empty.
* Explicit JPQL — same trap as existsByGeschichteIdAndDocumentId: the transient
* getDocumentId() getter makes Spring Data unable to resolve a derived query path.
* clearAutomatically = true invalidates the L1 cache so AC-2's "note-carrying survives"
* assertion never reads a stale entity. flushAutomatically = true makes the
* flush-before-delete contract explicit rather than relying on Hibernate AUTO flush mode.
*/
@Modifying(clearAutomatically = true, flushAutomatically = 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);
/**
* 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,276 @@
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.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.dao.DataIntegrityViolationException;
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;
// 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
static final int MAX_NOTE_LENGTH = 2000;
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,
"Geschichte not found: " + geschichteId));
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.JOURNEY_NOTE_TOO_LONG,
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
}
Document doc = null;
if (dto.getDocumentId() != null) {
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
"Document already in journey: " + dto.getDocumentId());
}
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();
// saveAndFlush so the partial unique index on (geschichte_id, document_id)
// fires here, not at commit — two concurrent appends can both pass the
// exists() pre-check above, and the index is the atomic backstop (V74).
JourneyItem saved;
try {
saved = journeyItemRepository.saveAndFlush(item);
} catch (DataIntegrityViolationException e) {
// Only the dedup index earns the friendly 409 — any other integrity
// failure (e.g. an FK violation on a concurrently deleted document)
// must not be mislabeled as "already added".
if (!isDuplicateDocumentViolation(e)) {
throw e;
}
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
"Document already in journey: " + dto.getDocumentId());
}
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.JOURNEY_NOTE_TOO_LONG,
"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,
"Geschichte 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 boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
Throwable cause = e.getCause();
if (cause instanceof java.sql.SQLException sql) {
return "23505".equals(sql.getSQLState());
}
return false;
}
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,17 @@
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,
/** Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output. */
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

@@ -1,26 +0,0 @@
package org.raddatz.familienarchiv.search;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate;
import java.util.List;
public record NlQueryInterpretation(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<PersonHint> resolvedPersons,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<PersonHint> ambiguousPersons,
LocalDate dateFrom,
LocalDate dateTo,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<String> keywords,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<TagHint> resolvedTags,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String rawQuery,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
boolean keywordsApplied,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
boolean tagsApplied
) {
}

View File

@@ -1,216 +0,0 @@
package org.raddatz.familienarchiv.search;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.SearchFilters;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.NameMatches;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagOperator;
import org.raddatz.familienarchiv.tag.TagService;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class NlQueryParserService {
private static final int MIN_QUERY = 3;
private static final int MAX_QUERY = 500;
private static final int MAX_NAME_LENGTH = 200;
private static final int MIN_TAG_TERM = 3;
private static final int MAX_RESOLVED_TAGS = 10;
private final OllamaClient ollamaClient;
private final PersonService personService;
private final DocumentService documentService;
private final TagService tagService;
public NlSearchResponse search(String query, Pageable pageable) {
if (query == null || query.length() < MIN_QUERY || query.length() > MAX_QUERY) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
"Query must be between " + MIN_QUERY + " and " + MAX_QUERY + " characters");
}
OllamaExtraction ext = ollamaClient.parse(query);
List<String> personNames = ext.personNames() != null ? ext.personNames() : List.of();
List<String> keywords = ext.keywords() != null ? ext.keywords() : List.of();
TagResolution tagResolution = resolveTags(keywords);
List<TagHint> resolvedTagHints = tagResolution.hints();
List<String> resolvedTagNames = tagResolution.tagNames();
List<String> remainingKeywords = tagResolution.remaining();
NameResolution resolution = resolveNames(personNames);
if (!resolution.ambiguous().isEmpty()) {
NlQueryInterpretation interpretation = new NlQueryInterpretation(
List.of(), resolution.ambiguous(),
ext.dateFrom(), ext.dateTo(),
keywords, List.of(), ext.rawQuery(), false, false);
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation);
}
List<PersonHint> resolved = resolution.resolved();
List<String> noMatchFragments = resolution.noMatchFragments();
List<String> extraFragments = resolution.extraFragments();
boolean hadStructuredMatch = !resolvedTagHints.isEmpty() || !resolved.isEmpty();
String text = buildText(remainingKeywords, noMatchFragments, extraFragments, ext.rawQuery(), hadStructuredMatch);
if (resolved.size() == 1 && isAnyRole(ext.personRole())) {
UUID personId = resolved.get(0).id();
DocumentSearchResult docs = documentService.searchDocumentsByPersonId(
personId, ext.dateFrom(), ext.dateTo(), pageable);
NlQueryInterpretation interpretation = new NlQueryInterpretation(
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), false, false);
return new NlSearchResponse(docs, interpretation);
}
UUID sender = buildSender(resolved, ext.personRole());
UUID receiver = buildReceiver(resolved, ext.personRole());
boolean tagsApplied = !resolvedTagHints.isEmpty();
TagOperator tagOperator = tagsApplied ? TagOperator.OR : TagOperator.AND;
SearchFilters filters = new SearchFilters(
text.isBlank() ? null : text,
ext.dateFrom(), ext.dateTo(),
sender, receiver,
resolvedTagNames, null,
null, tagOperator, false);
DocumentSearchResult docs = documentService.searchDocuments(filters, DocumentSort.DATE, "desc", pageable);
boolean keywordsApplied = !text.isBlank();
NlQueryInterpretation interpretation = new NlQueryInterpretation(
resolved, List.of(), ext.dateFrom(), ext.dateTo(), keywords, resolvedTagHints, ext.rawQuery(), keywordsApplied, tagsApplied);
return new NlSearchResponse(docs, interpretation);
}
private NameResolution resolveNames(List<String> personNames) {
List<PersonHint> resolved = new ArrayList<>();
List<PersonHint> ambiguous = new ArrayList<>();
List<String> noMatchFragments = new ArrayList<>();
List<String> extraFragments = new ArrayList<>();
int resolvedIndex = 0;
for (String name : personNames) {
if (name == null || name.length() > MAX_NAME_LENGTH) {
log.debug("Skipping name fragment (too long or null): length={}", name == null ? 0 : name.length());
continue;
}
NameMatches matches = personService.resolveByName(name);
List<Person> direct = matches.direct();
List<Person> partial = matches.partial();
if (direct.size() == 1) {
Person p = direct.get(0);
resolvedIndex++;
if (resolvedIndex <= 2) {
resolved.add(new PersonHint(p.getId(), p.getDisplayName()));
} else {
extraFragments.add(name);
}
} else if (direct.size() >= 2) {
direct.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
} else if (!partial.isEmpty()) {
partial.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
} else {
noMatchFragments.add(name);
}
}
return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments);
}
private TagResolution resolveTags(List<String> keywords) {
LinkedHashSet<Tag> seen = new LinkedHashSet<>();
List<String> remaining = new ArrayList<>();
for (String kw : keywords) {
if (kw == null || kw.length() < MIN_TAG_TERM) {
remaining.add(kw);
continue;
}
List<Tag> matches = tagService.findByNameContaining(kw);
if (matches.isEmpty()) {
remaining.add(kw);
} else {
seen.addAll(matches);
}
}
if (seen.size() > MAX_RESOLVED_TAGS) {
log.debug("Keyword matched {} tags; capping at {}", seen.size(), MAX_RESOLVED_TAGS);
}
List<Tag> capped = seen.size() > MAX_RESOLVED_TAGS
? new ArrayList<>(seen).subList(0, MAX_RESOLVED_TAGS)
: new ArrayList<>(seen);
// safe: entities are detached here; mutation is for DTO projection only, no dirty-check fires
tagService.resolveEffectiveColors(capped);
List<TagHint> hints = capped.stream()
.map(t -> new TagHint(t.getId(), t.getName(), t.getColor()))
.toList();
List<String> tagNames = capped.stream().map(Tag::getName).toList();
return new TagResolution(hints, tagNames, remaining);
}
private String buildText(List<String> keywords, List<String> noMatchFragments,
List<String> extraFragments, String rawQuery, boolean hadStructuredMatch) {
List<String> parts = new ArrayList<>();
parts.addAll(keywords);
parts.addAll(noMatchFragments);
parts.addAll(extraFragments);
String text = String.join(" ", parts).strip();
if (text.isBlank() && !hadStructuredMatch && rawQuery != null && !rawQuery.isBlank()) {
return rawQuery;
}
return text;
}
private boolean isAnyRole(String role) {
return role == null || "any".equals(role) || (!"sender".equals(role) && !"receiver".equals(role));
}
private UUID buildSender(List<PersonHint> resolved, String role) {
if (resolved.size() >= 2) return resolved.get(0).id();
if (resolved.size() == 1 && "sender".equals(role)) return resolved.get(0).id();
return null;
}
private UUID buildReceiver(List<PersonHint> resolved, String role) {
if (resolved.size() >= 2) return resolved.get(1).id();
if (resolved.size() == 1 && "receiver".equals(role)) return resolved.get(0).id();
return null;
}
private record NameResolution(
List<PersonHint> resolved,
List<PersonHint> ambiguous,
List<String> noMatchFragments,
List<String> extraFragments
) {}
private record TagResolution(
List<TagHint> hints,
List<String> tagNames,
List<String> remaining
) {}
}

View File

@@ -1,28 +0,0 @@
package org.raddatz.familienarchiv.search;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/search/nl")
@RequiredArgsConstructor
public class NlSearchController {
private final NlQueryParserService nlQueryParserService;
private final NlSearchRateLimiter rateLimiter;
@PostMapping
@RequirePermission(Permission.READ_ALL)
public NlSearchResponse search(@Valid @RequestBody NlSearchRequest request,
Pageable pageable,
@AuthenticationPrincipal UserDetails principal) {
rateLimiter.checkAndConsume(principal.getUsername());
return nlQueryParserService.search(request.query(), pageable);
}
}

View File

@@ -1,12 +0,0 @@
package org.raddatz.familienarchiv.search;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties("app.nl-search.rate-limit")
@Data
public class NlSearchRateLimitProperties {
private int maxRequestsPerMinute = 5;
}

View File

@@ -1,46 +0,0 @@
package org.raddatz.familienarchiv.search;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@Service
public class NlSearchRateLimiter {
private final LoadingCache<String, Bucket> byUser;
private final int maxRequestsPerMinute;
public NlSearchRateLimiter(NlSearchRateLimitProperties props) {
this.maxRequestsPerMinute = props.getMaxRequestsPerMinute();
this.byUser = Caffeine.newBuilder()
.expireAfterAccess(1, TimeUnit.MINUTES)
.build(key -> newBucket(maxRequestsPerMinute));
}
public void checkAndConsume(String userKey) {
if (!byUser.get(userKey).tryConsume(1)) {
throw DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_RATE_LIMITED,
"NL search rate limit exceeded for user: " + userKey, 60L);
}
}
void resetForTest() {
byUser.invalidateAll();
}
private static Bucket newBucket(int limit) {
return Bucket.builder()
.addLimit(Bandwidth.builder()
.capacity(limit)
.refillGreedy(limit, Duration.ofMinutes(1))
.build())
.build();
}
}

View File

@@ -1,11 +0,0 @@
package org.raddatz.familienarchiv.search;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record NlSearchRequest(
@NotBlank
@Size(min = 3, max = 500)
String query
) {
}

View File

@@ -1,12 +0,0 @@
package org.raddatz.familienarchiv.search;
import io.swagger.v3.oas.annotations.media.Schema;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
public record NlSearchResponse(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
DocumentSearchResult result,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
NlQueryInterpretation interpretation
) {
}

View File

@@ -1,5 +0,0 @@
package org.raddatz.familienarchiv.search;
public interface OllamaClient {
OllamaExtraction parse(String query);
}

View File

@@ -1,18 +0,0 @@
package org.raddatz.familienarchiv.search;
import java.time.LocalDate;
import java.util.List;
/**
* Raw structured output from Ollama after parsing and sanitising.
* personRole is always one of "sender", "receiver", "any" — defensive parsing ensures this.
*/
record OllamaExtraction(
List<String> personNames,
String personRole,
LocalDate dateFrom,
LocalDate dateTo,
List<String> keywords,
String rawQuery
) {
}

View File

@@ -1,5 +0,0 @@
package org.raddatz.familienarchiv.search;
public interface OllamaHealthClient {
boolean isHealthy();
}

View File

@@ -1,15 +0,0 @@
package org.raddatz.familienarchiv.search;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties("app.ollama")
@Data
public class OllamaProperties {
private String baseUrl;
private String model;
private int timeoutSeconds = 30;
private int healthCheckTimeoutSeconds = 2;
}

View File

@@ -1,13 +0,0 @@
package org.raddatz.familienarchiv.search;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
public record PersonHint(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String displayName
) {
}

View File

@@ -1,184 +0,0 @@
package org.raddatz.familienarchiv.search;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.LocalDate;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@Slf4j
public class RestClientOllamaClient implements OllamaClient, OllamaHealthClient {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Set<String> VALID_ROLES = Set.of("sender", "receiver", "any");
private static final int MAX_NAME_LENGTH = 200;
private static final int MAX_KEYWORD_LENGTH = 100;
private static final Map<String, Object> JSON_SCHEMA = Map.of(
"type", "object",
"required", List.of("personNames", "personRole", "keywords"),
"properties", Map.of(
"personNames", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_NAME_LENGTH)),
"personRole", Map.of("type", "string", "enum", List.of("sender", "receiver", "any")),
"dateFrom", Map.of("type", List.of("string", "null"), "maxLength", 20),
"dateTo", Map.of("type", List.of("string", "null"), "maxLength", 20),
"keywords", Map.of("type", "array", "items", Map.of("type", "string", "maxLength", MAX_KEYWORD_LENGTH))
)
);
private final RestClient inferenceClient;
private final RestClient healthClient;
private final OllamaProperties props;
public RestClientOllamaClient(OllamaProperties props) {
this.props = props;
HttpClient inferenceHttp = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
.build();
JdkClientHttpRequestFactory inferenceFactory = new JdkClientHttpRequestFactory(inferenceHttp);
inferenceFactory.setReadTimeout(Duration.ofSeconds(props.getTimeoutSeconds()));
this.inferenceClient = RestClient.builder()
.baseUrl(props.getBaseUrl())
.requestFactory(inferenceFactory)
.build();
HttpClient healthHttp = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()))
.build();
JdkClientHttpRequestFactory healthFactory = new JdkClientHttpRequestFactory(healthHttp);
healthFactory.setReadTimeout(Duration.ofSeconds(props.getHealthCheckTimeoutSeconds()));
this.healthClient = RestClient.builder()
.baseUrl(props.getBaseUrl())
.requestFactory(healthFactory)
.build();
}
@Override
public OllamaExtraction parse(String query) {
try {
OllamaGenerateRequest request = new OllamaGenerateRequest(
props.getModel(), query, JSON_SCHEMA, false);
String responseBody = inferenceClient.post()
.uri("/api/generate")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(String.class);
return parseOllamaResponse(responseBody, query);
} catch (DomainException e) {
throw e;
} catch (Exception e) {
log.warn("Ollama inference failed: {}", e.getClass().getSimpleName());
throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE,
"Ollama unavailable: " + e.getClass().getSimpleName());
}
}
@Override
public boolean isHealthy() {
try {
healthClient.get().uri("/api/tags").retrieve().toBodilessEntity();
return true;
} catch (Exception e) {
return false;
}
}
private OllamaExtraction parseOllamaResponse(String responseBody, String rawQuery) {
try {
OllamaGenerateResponse response = MAPPER.readValue(responseBody, OllamaGenerateResponse.class);
String inner = response.response();
if (inner == null || inner.isBlank()) {
return fallbackExtraction(rawQuery);
}
RawOllamaOutput raw = MAPPER.readValue(inner, RawOllamaOutput.class);
return toExtraction(raw, rawQuery);
} catch (Exception e) {
log.warn("Failed to parse Ollama response: {}", e.getClass().getSimpleName());
throw DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE,
"Failed to parse Ollama response: " + e.getClass().getSimpleName());
}
}
private OllamaExtraction toExtraction(RawOllamaOutput raw, String rawQuery) {
List<String> names = raw.personNames() == null ? List.of() : raw.personNames().stream()
.filter(n -> n != null && n.length() <= MAX_NAME_LENGTH)
.toList();
List<String> keywords = raw.keywords() == null ? List.of() : raw.keywords().stream()
.filter(k -> k != null && k.length() <= MAX_KEYWORD_LENGTH)
.toList();
String role = sanitiseRole(raw.personRole());
LocalDate dateFrom = parseDate(raw.dateFrom(), true);
LocalDate dateTo = parseDate(raw.dateTo(), false);
return new OllamaExtraction(names, role, dateFrom, dateTo, keywords, rawQuery);
}
private OllamaExtraction fallbackExtraction(String rawQuery) {
return new OllamaExtraction(List.of(), "any", null, null, List.of(), rawQuery);
}
private String sanitiseRole(String role) {
if (role != null && VALID_ROLES.contains(role)) {
return role;
}
log.warn("Unexpected personRole from Ollama: {}", role);
return "any";
}
private LocalDate parseDate(String raw, boolean isFrom) {
if (raw == null || raw.isBlank()) return null;
try {
return LocalDate.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE);
} catch (DateTimeParseException ignored) {
}
try {
int year = Integer.parseInt(raw.strip());
if (year > 1000 && year < 3000) {
return isFrom ? Year.of(year).atDay(1) : Year.of(year).atMonth(12).atEndOfMonth();
}
} catch (NumberFormatException ignored) {
}
return null;
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record OllamaGenerateResponse(String response) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record RawOllamaOutput(
@JsonProperty("personNames") List<String> personNames,
@JsonProperty("personRole") String personRole,
@JsonProperty("dateFrom") String dateFrom,
@JsonProperty("dateTo") String dateTo,
@JsonProperty("keywords") List<String> keywords
) {
}
private record OllamaGenerateRequest(
String model,
String prompt,
Object format,
boolean stream
) {
}
}

View File

@@ -1,14 +0,0 @@
package org.raddatz.familienarchiv.search;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.UUID;
public record TagHint(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
UUID id,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
String name,
String color
) {
}

View File

@@ -12,6 +12,3 @@ springdoc:
enabled: true
path: /swagger-ui.html
app:
ollama:
base-url: http://localhost:11434

View File

@@ -130,18 +130,6 @@ app:
# The loader maps columns by header name — no positional indices (see ADR-025).
dir: ${IMPORT_DIR:/import}
ollama:
base-url: http://ollama:11434
model: qwen2.5:7b-instruct-q4_K_M
# CPU inference: ~18s warm. Higher ceiling absorbs the cold model load on the
# first query after an Ollama (re)start before OLLAMA_KEEP_ALIVE pins it.
timeout-seconds: 60
health-check-timeout-seconds: 2
nl-search:
rate-limit:
max-requests-per-minute: 5
ocr:
sender-model:
activation-threshold: 100

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

@@ -0,0 +1,37 @@
-- Two constraints the service-level checks need as atomic backstops:
--
-- 1. Partial unique index on (geschichte_id, document_id): the append dedup
-- guard is a check-then-insert (existsByGeschichteIdAndDocumentId), so two
-- concurrent appends of the same document can both pass the pre-check.
-- The index rejects the second INSERT; JourneyItemService.append translates
-- the DataIntegrityViolationException into the same 409
-- JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check.
-- Partial (WHERE document_id IS NOT NULL) — note-only interludes must not collide.
--
-- 2. CHECK on note length: mirrors chk_text_length on transcription_blocks.
-- 2000 is the spec'd limit — JourneyItemService.MAX_NOTE_LENGTH, the frontend
-- maxlength, and the i18n error message all agree (#793).
--
-- Defensive cleanup first: a database that served writes on the base branch
-- (no dedup guard, MAX_NOTE_LENGTH = 5000) can hold rows that would make the
-- DDL below fail mid-migration and boot-loop the backend on a failed Flyway
-- row. Both statements are no-ops on a clean database.
-- Keep the earliest-positioned row of each (geschichte, document) pair.
DELETE FROM journey_items a
USING journey_items b
WHERE a.geschichte_id = b.geschichte_id
AND a.document_id = b.document_id
AND a.document_id IS NOT NULL
AND a.position > b.position;
-- Clamp over-long notes written under the old 5000-char service limit.
UPDATE journey_items SET note = left(note, 2000) WHERE length(note) > 2000;
CREATE UNIQUE INDEX uq_journey_items_geschichte_document
ON journey_items (geschichte_id, document_id)
WHERE document_id IS NOT NULL;
ALTER TABLE journey_items
ADD CONSTRAINT chk_journey_item_note_length
CHECK (note IS NULL OR length(note) <= 2000);

View File

@@ -0,0 +1,16 @@
-- JOURNEY intros travel the verbatim (unsanitized) write path and get the same
-- three-layer bound as journey notes: frontend maxlength, the
-- GeschichteService.MAX_INTRO_LENGTH check, and this CHECK as the atomic backstop.
-- STORY bodies are sanitized Tiptap HTML and stay unbounded on purpose.
--
-- The title needs no CHECK here — VARCHAR(255) (V58) already bounds it at the
-- DB layer; the service-level check exists to turn that 500 into a friendly 400.
-- Defensive clamp first: intros written before this migration may exceed the
-- cap. No-op on a clean database.
UPDATE geschichten SET body = left(body, 4000)
WHERE type = 'JOURNEY' AND length(body) > 4000;
ALTER TABLE geschichten
ADD CONSTRAINT chk_geschichte_journey_intro_length
CHECK (type <> 'JOURNEY' OR body IS NULL OR length(body) <= 4000);

View File

@@ -402,6 +402,7 @@ class DocumentControllerTest {
@WithMockUser(authorities = "WRITE_ALL")
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
UUID id = UUID.randomUUID();
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
.delete("/api/documents/" + id).with(csrf()))
.andExpect(status().isNoContent());

View File

@@ -131,6 +131,28 @@ class DocumentLazyLoadingTest {
.doesNotThrowAnyException();
}
@Test
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
// q + default sort + no other filters → the relevance fast path
// (relevanceSortedPageFromSql), which loads documents by id outside any
// transaction and must still deliver an initialized tags collection.
Person sender = savedPerson("Hans", "FtSender");
Tag tag = savedTag("FtTag");
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
SearchFilters textOnly = new SearchFilters(
"Walter", null, null, null, null, null, null, null, null, false);
DocumentSearchResult result = documentService.searchDocuments(
textOnly, null, "DESC", PageRequest.of(0, 10));
assertThat(result.totalElements()).isEqualTo(1);
assertThatCode(() ->
result.items().forEach(i -> i.tags().size()))
.doesNotThrowAnyException();
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
}
@Test
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
Person sender = savedPerson("Hans", "SsSender");

View File

@@ -81,7 +81,7 @@ class DocumentServiceSortTest {
UUID id1 = UUID.randomUUID();
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any()))
when(documentRepository.findByIdIn(any()))
.thenReturn(List.of(doc(id1)));
documentService.searchDocuments(
@@ -101,7 +101,7 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
@@ -119,7 +119,7 @@ class DocumentServiceSortTest {
ftsRows.add(new Object[]{id1, 0.8d, 2L});
ftsRows.add(new Object[]{id2, 0.3d, 2L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
List<Object[]> ftsRows = new ArrayList<>();
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
DocumentSearchResult result = documentService.searchDocuments(
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),

View File

@@ -30,6 +30,7 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.filestorage.FileService;
import org.raddatz.familienarchiv.tag.TagService;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
@@ -75,6 +76,7 @@ class DocumentServiceTest {
@Mock AuditLogQueryService auditLogQueryService;
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
@Mock ApplicationEventPublisher eventPublisher;
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
// shared composition rather than a stub — the #726 single source of truth.
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
@@ -87,7 +89,7 @@ class DocumentServiceTest {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(true);
documentService.deleteDocument(id);
documentService.deleteDocument(id, UUID.randomUUID());
verify(documentRepository).deleteById(id);
}
@@ -97,7 +99,7 @@ class DocumentServiceTest {
UUID id = UUID.randomUUID();
when(documentRepository.existsById(id)).thenReturn(false);
assertThatThrownBy(() -> documentService.deleteDocument(id))
assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
.isInstanceOf(DomainException.class)
.hasMessageContaining(id.toString());
verify(documentRepository, never()).deleteById(any());
@@ -2166,7 +2168,7 @@ class DocumentServiceTest {
List<Object[]> ftsRows = new java.util.ArrayList<>();
ftsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(
@@ -2202,7 +2204,7 @@ class DocumentServiceTest {
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
DocumentSearchResult result = documentService.searchDocuments(

View File

@@ -0,0 +1,66 @@
package org.raddatz.familienarchiv.geschichte;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
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 geschichten — deliberately NOT @Transactional at
* class level (see JourneyItemConstraintsTest for the rationale).
*
* The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on
* the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class GeschichteConstraintsTest {
@MockitoBean
S3Client s3Client;
@Autowired JdbcTemplate jdbcTemplate;
private UUID insertGeschichte(String type, String body) {
UUID id = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) "
+ "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())",
id, "Constraints-Test", body, type);
return id;
}
@Test
void journey_intro_check_rejects_4001_chars() {
assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001)))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void journey_intro_check_accepts_exactly_4000_chars() {
UUID id = insertGeschichte("JOURNEY", "x".repeat(4000));
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
assertThat(count).isEqualTo(1);
}
@Test
void story_bodies_are_not_constrained_by_the_intro_check() {
UUID id = insertGeschichte("STORY", "<p>" + "x".repeat(4001) + "</p>");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
assertThat(count).isEqualTo(1);
}
}

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 ────────────────────────────────────────────────
@@ -65,7 +64,7 @@ class GeschichteControllerTest {
@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")));
.thenReturn(List.of(summaryStub("Story A")));
mockMvc.perform(get("/api/geschichten"))
.andExpect(status().isOk())
@@ -101,13 +100,50 @@ class GeschichteControllerTest {
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesDocumentIdFilterToService() throws Exception {
UUID documentId = UUID.randomUUID();
when(geschichteService.list(any(), any(), eq(documentId), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("documentId", documentId.toString()))
.andExpect(status().isOk());
verify(geschichteService).list(any(), any(), eq(documentId), anyInt());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesLimitToService() throws Exception {
when(geschichteService.list(any(), any(), any(), eq(5)))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("limit", "5"))
.andExpect(status().isOk());
verify(geschichteService).list(any(), any(), any(), eq(5));
}
@Test
@WithMockUser(authorities = "READ_ALL")
void list_passesStatusFilterToService() throws Exception {
when(geschichteService.list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt()))
.thenReturn(List.of());
mockMvc.perform(get("/api/geschichten").param("status", "PUBLISHED"))
.andExpect(status().isOk());
verify(geschichteService).list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt());
}
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
@Test
@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 +155,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))
@@ -151,7 +187,7 @@ class GeschichteControllerTest {
void create_returns201_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
.thenReturn(draft(id, "New"));
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("New");
@@ -179,7 +215,7 @@ class GeschichteControllerTest {
void update_returns200_withBlogWrite() throws Exception {
UUID id = UUID.randomUUID();
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
.thenReturn(published(id, "Updated"));
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
.contentType(MediaType.APPLICATION_JSON)
@@ -208,31 +244,202 @@ 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 Geschichte published(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.body("<p>x</p>")
.status(GeschichteStatus.PUBLISHED)
.publishedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
private JourneyItemView itemViewStub(UUID id, int position, String note) {
return new JourneyItemView(id, position, null, note);
}
private Geschichte draft(UUID id, String title) {
return Geschichte.builder()
.id(id)
.title(title)
.status(GeschichteStatus.DRAFT)
.createdAt(LocalDateTime.now())
.updatedAt(LocalDateTime.now())
.persons(new HashSet<>())
.documents(new HashSet<>())
.build();
private GeschichteView viewStub(UUID id, String title) {
return viewStub(id, title, GeschichteStatus.PUBLISHED);
}
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
return new GeschichteView(id, title, "<p>x</p>",
status, 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 LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
public String getBody() { return null; }
};
}
}

View File

@@ -0,0 +1,298 @@
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.raddatz.familienarchiv.user.UserGroup;
import org.raddatz.familienarchiv.user.UserGroupRepository;
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.http.client.JdkClientHttpRequestFactory;
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.Set;
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 UserGroupRepository userGroupRepository;
@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.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::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);
}
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
@Test
void update_returns_200_and_serializes_items_open_in_view_false() {
// Canonical guard for the write path: PATCH must not 500 when the response
// is serialized after the service transaction closed. The raw entity carries
// a dead lazy items proxy at that point — the endpoint must answer with a
// view assembled inside the transaction.
AppUser writer = blogWriter();
Geschichte journey = Geschichte.builder()
.title("Reise vor dem Umbenennen")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.author(writer)
.items(new ArrayList<>())
.persons(new HashSet<>())
.build();
journey.getItems().add(JourneyItem.builder()
.geschichte(journey).position(1000).note("Prolog").build());
Geschichte saved = geschichteRepository.save(journey);
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
ResponseEntity<String> response = http.exchange(
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody())
.contains("Reise nach dem Umbenennen")
.contains("Prolog");
}
// ─── helpers ─────────────────────────────────────────────────────────────
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
private AppUser blogWriter() {
UserGroup group = userGroupRepository.save(UserGroup.builder()
.name("HttpTest-BlogWriters")
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
.build());
return appUserRepository.save(AppUser.builder()
.email(BLOG_WRITER_EMAIL)
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
.groups(new HashSet<>(Set.of(group)))
.build());
}
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
private HttpHeaders csrfJsonHeaders(String sessionId) {
String xsrf = UUID.randomUUID().toString();
HttpHeaders headers = new HttpHeaders();
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
headers.set("X-XSRF-TOKEN", xsrf);
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
private String loginAsWriter() {
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
}
private String loginAs(String email, String password) {
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\":\"" + email + "\",\"password\":\"" + 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() {
// JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
template.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return false;
}
});
return template;
}
}

View File

@@ -0,0 +1,262 @@
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.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
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;
@Autowired DocumentRepository documentRepository;
@Autowired JourneyItemRepository journeyItemRepository;
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, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
}
@Test
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
// projection must carry it for drafts, where publishedAt is null.
geschichteRepository.save(draft("Mein Entwurf", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getUpdatedAt()).isNotNull();
}
@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, null);
assertThat(result).isEmpty();
}
// ─── AuthorSummary nested projection ─────────────────────────────────────
@Test
void findSummaries_exposes_nested_author_names_but_never_email() {
AppUser richAuthor = appUserRepository.save(AppUser.builder()
.firstName("Franz").lastName("Raddatz")
.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, null);
assertThat(result).hasSize(1);
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
assertThat(a.getFirstName()).isEqualTo("Franz");
assertThat(a.getLastName()).isEqualTo("Raddatz");
// Design rule (GeschichteView.AuthorView javadoc): author projections never
// expose email or group memberships to readers.
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
.extracting(java.lang.reflect.Method::getName)
.doesNotContain("getEmail");
}
// ─── 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, null);
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, null);
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, null);
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, null);
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, null);
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Both");
}
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
@Test
void findSummaries_with_documentId_returns_journey_containing_that_document() {
Document doc = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
journeyItemRepository.save(JourneyItem.builder()
.geschichte(withDoc).document(doc).position(1).build());
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
assertThat(result).hasSize(1);
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
}
@Test
void findSummaries_with_unknown_documentId_returns_empty() {
geschichteRepository.save(journey("Irgendeine Reise", author));
List<GeschichteSummary> result = geschichteRepository.findSummaries(
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
assertThat(result).isEmpty();
}
// ─── 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();
}
private Geschichte journey(String title, AppUser writer) {
return Geschichte.builder()
.title(title)
.status(GeschichteStatus.PUBLISHED)
.type(GeschichteType.JOURNEY)
.author(writer)
.publishedAt(LocalDateTime.now())
.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;
@@ -76,11 +80,11 @@ class GeschichteServiceIntegrationTest {
+ "<script>alert('xss')</script>");
dto.setPersonIds(List.of(franz.getId()));
Geschichte created = geschichteService.create(dto);
GeschichteView created = geschichteService.create(dto);
assertThat(created.getId()).isNotNull();
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.getBody())
assertThat(created.id()).isNotNull();
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(created.body())
.contains("<strong>jeden Sonntag</strong>")
.doesNotContain("<script>");
@@ -89,7 +93,7 @@ class GeschichteServiceIntegrationTest {
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
UUID draftId = created.getId();
UUID draftId = created.id();
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
.hasMessageContaining("not found");
@@ -97,16 +101,17 @@ class GeschichteServiceIntegrationTest {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
publishDto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.getPublishedAt()).isNotNull();
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
assertThat(publishedGesch.publishedAt()).isNotNull();
// 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);
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);
@@ -137,17 +142,17 @@ class GeschichteServiceIntegrationTest {
// No filter → all three
assertThat(geschichteService.list(null, List.of(), null, 50))
.extracting(Geschichte::getId)
.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)
.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)
.extracting(GeschichteSummary::getId)
.containsExactly(storyAB);
// AND: Bertha AND Carl → none (no story has both)
@@ -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(), null, 50);
assertThat(result).isEmpty();
}
@@ -185,7 +190,7 @@ class GeschichteServiceIntegrationTest {
dto.setBody("<p>body</p>");
dto.setPersonIds(personIds);
dto.setStatus(GeschichteStatus.PUBLISHED);
return geschichteService.create(dto).getId();
return geschichteService.create(dto).id();
}
private void authenticateAs(AppUser user, Permission... permissions) {

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,10 @@ 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.anyLong;
import static org.mockito.ArgumentMatchers.eq;
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 +44,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 +91,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 +104,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 +120,190 @@ 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(), any()))
.thenReturn(List.of());
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
geschichteService.list(null, List.of(), null, 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(), any());
}
@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(), any()))
.thenReturn(List.of(s1, s2));
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 50);
assertThat(out).hasSize(2);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@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(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(personId), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@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(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(a, b), null, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
}
@Test
void list_filters_by_documentId() {
void list_passes_documentId_to_repository_as_journey_item_filter() {
authenticateAs(reader, Permission.READ_ALL);
UUID documentId = UUID.randomUUID();
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of());
geschichteService.list(null, List.of(), documentId, 50);
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
}
@Test
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
void list_passes_nil_uuid_sentinel_to_repository_when_no_person_filter_given() {
// B2: when personIds is empty/null the service must pass a sentinel NIL UUID
// so the IN() predicate is skipped without producing invalid empty-IN() SQL.
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(), any()))
.thenReturn(List.of());
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
geschichteService.list(null, List.of(), null, 50);
UUID nilUUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
verify(geschichteRepository).findSummaries(
any(), any(), org.mockito.ArgumentMatchers.argThat(ids -> ids.contains(nilUUID)), anyLong(), any());
}
@Test
void list_caps_limit_at_max_when_caller_passes_huge_value() {
authenticateAs(reader, Permission.READ_ALL);
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
.thenReturn(List.of(mock(GeschichteSummary.class)));
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
assertThat(out).hasSizeLessThanOrEqualTo(200);
}
@@ -213,11 +321,11 @@ class GeschichteServiceTest {
dto.setTitle("My Story");
dto.setBody("<p>plain text</p>");
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
assertThat(saved.getAuthor()).isSameAs(writer);
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.publishedAt()).isNull();
assertThat(saved.author().id()).isEqualTo(writer.getId());
}
@Test
@@ -231,9 +339,9 @@ class GeschichteServiceTest {
dto.setTitle("XSS attempt");
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getBody())
assertThat(saved.body())
.contains("<p>safe</p>")
.doesNotContain("<script>")
.doesNotContain("onerror")
@@ -252,9 +360,9 @@ class GeschichteServiceTest {
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.getBody())
assertThat(saved.body())
.contains("<h2>Heading</h2>")
.contains("<strong>bold</strong>")
.contains("<em>italic</em>")
@@ -277,28 +385,9 @@ class GeschichteServiceTest {
dto.setTitle("Linked");
dto.setPersonIds(List.of(personId));
Geschichte saved = geschichteService.create(dto);
GeschichteView saved = geschichteService.create(dto);
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);
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
}
@Test
@@ -315,6 +404,202 @@ class GeschichteServiceTest {
.isEqualTo(ErrorCode.VALIDATION_ERROR);
}
@Test
void create_preserves_JOURNEY_type_from_dto() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("My Journey");
dto.setType(GeschichteType.JOURNEY);
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
}
@Test
void create_defaults_to_STORY_when_type_is_null() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("My Story");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
}
@Test
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
// The journey intro is plain text: JourneyReader renders it via Svelte text
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
// would corrupt real content ("Müller & Söhne" → "Müller &amp; Söhne") and
// re-encode cumulatively on every editor round-trip.
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("Müller & Söhne, Temperatur < 0");
GeschichteView saved = geschichteService.create(dto);
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
}
@Test
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.JOURNEY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("Temperatur < 0 & Schnee");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
}
@Test
void update_still_sanitizes_STORY_body() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
// ─── length caps ─────────────────────────────────────────────────────────
@Test
void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(256));
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
}
@Test
void create_accepts_title_of_exactly_255_chars() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(255));
assertThat(geschichteService.create(dto).title()).hasSize(255);
}
@Test
void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("x".repeat(256));
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
}
@Test
void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("x".repeat(4001));
assertThatThrownBy(() -> geschichteService.create(dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
}
@Test
void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
authenticateAs(writer, Permission.BLOG_WRITE);
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setTitle("Winterbriefe");
dto.setType(GeschichteType.JOURNEY);
dto.setBody("x".repeat(4000));
assertThat(geschichteService.create(dto).body()).hasSize(4000);
}
@Test
void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.JOURNEY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("x".repeat(4001));
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
}
@Test
void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
// STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
// the 4000-char cap exists for the verbatim JOURNEY intro path only.
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>" + "x".repeat(4001) + "</p>");
assertThat(geschichteService.update(id, dto).body()).contains("<p>");
}
// ─── update ──────────────────────────────────────────────────────────────
@Test
@@ -330,10 +615,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.PUBLISHED);
Geschichte saved = geschichteService.update(id, dto);
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.getPublishedAt()).isNotNull();
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
assertThat(saved.publishedAt()).isNotNull();
}
@Test
@@ -349,10 +634,10 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setStatus(GeschichteStatus.DRAFT);
Geschichte saved = geschichteService.update(id, dto);
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.getPublishedAt()).isNull();
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
assertThat(saved.publishedAt()).isNull();
}
@Test
@@ -366,9 +651,46 @@ class GeschichteServiceTest {
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setBody("<p>ok</p><script>alert(1)</script>");
Geschichte saved = geschichteService.update(id, dto);
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
}
@Test
void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.JOURNEY);
assertThatThrownBy(() -> geschichteService.update(id, dto))
.isInstanceOf(DomainException.class)
.extracting("code")
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
}
@Test
void update_accepts_dto_carrying_the_unchanged_type() {
authenticateAs(writer, Permission.BLOG_WRITE);
UUID id = UUID.randomUUID();
Geschichte existing = draft(id);
existing.setType(GeschichteType.STORY);
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
when(geschichteRepository.save(any(Geschichte.class)))
.thenAnswer(inv -> inv.getArgument(0));
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
dto.setType(GeschichteType.STORY);
dto.setTitle("Unverändert getypt");
GeschichteView saved = geschichteService.update(id, dto);
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
assertThat(saved.title()).isEqualTo("Unverändert getypt");
}
@Test
@@ -426,7 +748,7 @@ class GeschichteServiceTest {
.body("<p>body</p>")
.status(GeschichteStatus.DRAFT)
.persons(new HashSet<>())
.documents(new HashSet<>())
.items(new ArrayList<>())
.build();
}
@@ -438,7 +760,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,165 @@
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 unique_index_rejects_duplicate_document_per_geschichte() {
// Atomic backstop for the service-level dedup pre-check (check-then-insert race).
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, 20, documentId))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void unique_index_allows_same_document_in_different_journeys() {
Geschichte other = geschichteRepository.save(Geschichte.builder()
.title("Andere Lesereise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, documentId);
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), other.getId(), 10, documentId);
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE document_id = ?", Integer.class, documentId);
assertThat(count).isEqualTo(2);
}
@Test
void unique_index_allows_multiple_note_only_items() {
// document_id IS NULL rows must not collide — the index is partial.
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "erste Notiz");
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 20, "zweite Notiz");
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
assertThat(count).isEqualTo(2);
}
@Test
void note_length_check_rejects_2001_chars() {
assertThatThrownBy(() ->
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "x".repeat(2001)))
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void note_length_check_accepts_exactly_2000_chars() {
// Pins the boundary at the DB layer too — a future <= vs < migration edit
// must fail here, not only in the mock-based service test.
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
UUID.randomUUID(), geschichteId, 10, "x".repeat(2000));
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
assertThat(count).isEqualTo(1);
}
@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,261 @@
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.audit.AuditKind;
import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
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.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.jdbc.core.JdbcTemplate;
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.test.context.bean.override.mockito.MockitoSpyBean;
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;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
class JourneyItemDocumentDeleteTest {
@MockitoBean
S3Client s3Client;
@MockitoBean
AuditService auditService;
@MockitoSpyBean
DocumentRepository documentRepository;
@PersistenceContext
EntityManager em;
@Autowired DocumentService documentService;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired GeschichteRepository geschichteRepository;
@Autowired DocumentRepository docRepo;
@Autowired AppUserRepository appUserRepository;
@Autowired JdbcTemplate jdbcTemplate;
Geschichte journey;
Document doc;
AppUser writer;
@BeforeEach
void seed() {
writer = appUserRepository.save(AppUser.builder()
.email("delete-test-writer@test")
.password("hash")
.build());
doc = docRepo.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());
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(writer.getEmail(), null,
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
}
@AfterEach
void cleanup() {
SecurityContextHolder.clearContext();
reset(documentRepository);
// Deletion order is FK-load-bearing: journey_items reference both documents
// and geschichten, so children must be removed before their parents.
journeyItemRepository.deleteAll();
docRepo.deleteAll();
geschichteRepository.deleteAll();
appUserRepository.deleteAll();
}
// ─── AC-1: headline ───────────────────────────────────────────────────────
@Test
void deleting_document_linked_via_note_less_item_deletes_item_not_500() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
assertThat(docRepo.findById(doc.getId())).isEmpty();
}
// ─── AC-2: note-carrying item survives as placeholder ─────────────────────
@Test
void deleting_document_preserves_note_carrying_item_as_placeholder() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("curator context");
}
// ─── AC-3: note-only item untouched ───────────────────────────────────────
@Test
void deleting_document_does_not_affect_note_only_item() {
JourneyItem noteOnly = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
assertThat(reloaded.getDocumentId()).isNull();
assertThat(reloaded.getNote()).isEqualTo("Einleitung");
}
// ─── AC-4: asymmetric multi-journey ───────────────────────────────────────
@Test
void deleting_document_applies_independently_per_referencing_item() {
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
.title("Zweite Reise")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
JourneyItem noteLess = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
JourneyItem noteCarrying = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
JourneyItem surviving = journeyItemRepository.findById(noteCarrying.getId()).orElseThrow();
assertThat(surviving.getDocumentId()).isNull();
assertThat(surviving.getNote()).isEqualTo("Begleittext");
}
// ─── AC-5: rollback guard ─────────────────────────────────────────────────
@Test
void listener_deletes_roll_back_when_document_delete_fails() {
JourneyItem item = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
doThrow(new RuntimeException("simulated failure"))
.when(documentRepository).deleteById(any());
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId(), writer.getId()))
.isInstanceOf(RuntimeException.class);
em.clear();
assertThat(journeyItemRepository.findById(item.getId())).isPresent();
}
// ─── AC-6: empty-string note boundary ────────────────────────────────────
@Test
void empty_string_note_item_is_cascaded_whitespace_only_note_is_preserved() {
// uq_journey_items_geschichte_document prevents two items with the same
// (geschichte_id, document_id) in one journey — use two separate journeys.
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
.title("Zweite Reise für AC-6")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.JOURNEY)
.build());
UUID emptyNoteItemId = UUID.randomUUID();
UUID whitespaceNoteItemId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
emptyNoteItemId, journey.getId(), 10, doc.getId(), "");
jdbcTemplate.update(
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
em.clear();
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
JourneyItem whitespaceItem = journeyItemRepository.findById(whitespaceNoteItemId).orElseThrow();
assertThat(whitespaceItem.getDocumentId()).isNull();
assertThat(whitespaceItem.getNote()).isEqualTo(" ");
}
// ─── Idempotency / no-collateral ──────────────────────────────────────────
@Test
void deleting_document_in_zero_journeys_returns_no_collateral() {
Document unlinked = docRepo.save(Document.builder()
.title("Unverknüpfter Brief")
.originalFilename("other.pdf")
.status(DocumentStatus.UPLOADED)
.build());
JourneyItem unrelated = journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
em.clear();
documentService.deleteDocument(unlinked.getId(), writer.getId());
em.clear();
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
assertThat(journeyItemRepository.findById(unrelated.getId())).isPresent();
assertThat(journeyItemRepository.count()).isEqualTo(1);
}
// ─── AC-7: audit — DOCUMENT_DELETED emitted, JOURNEY_ITEM_REMOVED absent ─
@Test
void deleting_document_emits_document_audit_but_no_journey_item_audit() {
journeyItemRepository.save(
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
em.clear();
documentService.deleteDocument(doc.getId(), writer.getId());
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), eq(writer.getId()), eq(doc.getId()), any());
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
}
}

View File

@@ -0,0 +1,418 @@
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.audit.AuditService;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
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;
@MockitoBean
AuditService auditService;
@PersistenceContext
EntityManager em;
@Autowired GeschichteRepository geschichteRepository;
@Autowired JourneyItemRepository journeyItemRepository;
@Autowired JourneyItemService journeyItemService;
@Autowired DocumentService documentService;
@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();
// Distinct content per item — V74's partial unique index forbids the same
// document twice in one journey, and ordering doesn't depend on it.
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").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();
// Route through service so the DocumentDeletingEvent fires and the listener
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
documentService.deleteDocument(doc.getId(), writer.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");
}
@Test
void append_document_persists_and_rejects_duplicate() {
// Covers the document branch of append, including the duplicate guard —
// the derived exists query must resolve document.id, which the transient
// getDocumentId() getter on JourneyItem shadows for Spring Data.
authenticateAs(writer, Permission.BLOG_WRITE);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
em.flush();
em.clear();
assertThat(view.document()).isNotNull();
assertThat(view.document().id()).isEqualTo(doc.getId());
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
duplicate.setDocumentId(doc.getId());
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
.hasFieldOrPropertyWithValue("code",
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
}
// ─── STORY-type Geschichten hold journey items (#795) ────────────────────
@Test
void story_type_can_hold_journey_items_through_service() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
em.flush();
em.clear();
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
assertThat(items).hasSize(1);
assertThat(items.get(0).id()).isEqualTo(appended.id());
assertThat(items.get(0).document().id()).isEqualTo(doc.getId());
}
@Test
void v72_migrated_story_items_keep_position_order_and_are_removable() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
Document docB = documentRepository.save(Document.builder()
.title("Zweiter Brief").originalFilename("b.pdf").status(DocumentStatus.UPLOADED).build());
Document docC = documentRepository.save(Document.builder()
.title("Dritter Brief").originalFilename("c.pdf").status(DocumentStatus.UPLOADED).build());
// V72 inserted journey_items rows directly with position gaps — mirror that
// by writing through the repository instead of the service.
JourneyItem first = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(10).document(doc).build());
JourneyItem second = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(20).document(docB).build());
JourneyItem third = journeyItemRepository.save(
JourneyItem.builder().geschichte(story).position(30).document(docC).build());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId()))
.extracting(JourneyItemView::position)
.containsExactly(10, 20, 30);
journeyItemService.delete(story.getId(), second.getId());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId()))
.extracting(JourneyItemView::id)
.containsExactly(first.getId(), third.getId());
}
@Test
void story_item_with_deleted_document_survives_and_remains_deletable() {
authenticateAs(writer, Permission.BLOG_WRITE);
Geschichte story = savedStory();
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(doc.getId());
// The note keeps chk_journey_item_not_empty satisfied once ON DELETE
// SET NULL clears document_id — a note-less item would block the
// document delete at the DB instead.
dto.setNote("Begleittext");
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
em.flush();
em.clear();
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
documentService.deleteDocument(doc.getId(), writer.getId());
em.flush();
em.clear();
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
assertThat(items).hasSize(1);
assertThat(items.get(0).document()).isNull();
journeyItemService.delete(story.getId(), appended.id());
em.flush();
em.clear();
assertThat(journeyItemService.getItems(story.getId())).isEmpty();
}
private Geschichte savedStory() {
return geschichteRepository.save(Geschichte.builder()
.title("Eine Geschichte")
.status(GeschichteStatus.DRAFT)
.type(GeschichteType.STORY)
.build());
}
// ─── 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,822 @@
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 org.postgresql.util.PSQLException;
import org.postgresql.util.PSQLState;
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.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.saveAndFlush(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.saveAndFlush(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_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
// 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
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(2001));
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
}
@Test
void append_accepts_note_of_exactly_2000_chars() {
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, "x".repeat(2000));
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("x".repeat(2000));
assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
}
@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 append_returns409_when_document_already_in_journey() {
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_to_STORY_type_creates_journey_item() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(false);
Document doc = makeDoc(docId, null, List.of(), null, null);
when(documentService.findSummaryByIdInternal(docId)).thenReturn(doc);
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
when(journeyItemRepository.saveAndFlush(any())).thenReturn(savedItemWithDoc(itemId, story, 10, doc, null));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
JourneyItemView view = journeyItemService.append(geschichteId, dto);
assertThat(view.position()).isEqualTo(10);
assertThat(view.document().id()).isEqualTo(docId);
}
@Test
void append_to_STORY_type_respects_capacity_cap() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
}
@Test
void append_to_STORY_type_rejects_duplicate_document() {
Geschichte story = story(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(docId);
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@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.saveAndFlush(any())).thenReturn(saved);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setNote("Note");
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
}
@Test
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
// Two concurrent appends can both pass the exists() pre-check; the partial
// unique index then rejects the second INSERT at flush. The service must
// translate that into the same friendly 409 as the pre-check.
// Uses PSQLException with SQLState 23505 — the real payload Postgres delivers.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"could not execute statement", psqlEx));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
// B1: the dedup check must use PSQLException.getSQLState() == "23505", not
// constraint-name string matching — constraint renames must not regress this.
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
// Simulate a real Postgres unique-violation: PSQLException with SQLState 23505
// wrapped by Spring's DataIntegrityViolationException.
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
PSQLState.UNIQUE_VIOLATION);
org.springframework.dao.DataIntegrityViolationException dive =
new org.springframework.dao.DataIntegrityViolationException("could not execute statement", psqlEx);
when(journeyItemRepository.saveAndFlush(any())).thenThrow(dive);
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
}
@Test
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() throws Exception {
// An FK violation (document deleted between load and flush) must NOT be
// translated into "already added" — only the dedup unique index (23505) earns that 409.
// FK violations arrive as PSQLException with SQLState 23503 (foreign_key_violation).
Geschichte journey = journey(geschichteId);
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
PSQLException psqlEx = new PSQLException("foreign key violation", PSQLState.FOREIGN_KEY_VIOLATION);
when(journeyItemRepository.saveAndFlush(any()))
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
"could not execute statement", psqlEx));
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
dto.setDocumentId(UUID.randomUUID());
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
}
@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.saveAndFlush(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_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
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(2001)));
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getCode())
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
}
@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 Geschichte story(UUID id) {
return Geschichte.builder()
.id(id)
.title("Test Story")
.type(GeschichteType.STORY)
.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

@@ -1,739 +0,0 @@
package org.raddatz.familienarchiv.search;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentSort;
import org.raddatz.familienarchiv.document.SearchFilters;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.person.NameMatches;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagOperator;
import org.raddatz.familienarchiv.tag.TagService;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
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.*;
import static org.mockito.Mockito.*;
class NlQueryParserServiceTest {
@Mock OllamaClient ollamaClient;
@Mock PersonService personService;
@Mock DocumentService documentService;
@Mock TagService tagService;
NlQueryParserService service;
static final Pageable PAGE = PageRequest.of(0, 20);
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
service = new NlQueryParserService(ollamaClient, personService, documentService, tagService);
when(documentService.searchDocuments(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
when(documentService.searchDocumentsByPersonId(any(), any(), any(), any()))
.thenReturn(DocumentSearchResult.of(List.of()));
when(tagService.findByNameContaining(anyString())).thenReturn(List.of());
}
// --- Factory helpers ---
private OllamaExtraction extraction(List<String> names, String role, LocalDate from, LocalDate to,
List<String> keywords) {
String raw = names.isEmpty() ? "test query" : String.join(" ", names);
return new OllamaExtraction(names, role, from, to, keywords, raw);
}
private Person person(UUID id, String firstName, String lastName) {
return Person.builder().id(id).firstName(firstName).lastName(lastName).build();
}
private NameMatches makeNameMatches() {
return new NameMatches(List.of(), List.of());
}
private NameMatches makeNameMatches(List<Person> direct) {
return new NameMatches(direct, List.of());
}
private NameMatches makeNameMatches(List<Person> direct, List<Person> partial) {
return new NameMatches(direct, partial);
}
private static final UUID P1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final UUID P2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
private static final UUID P3 = UUID.fromString("00000000-0000-0000-0000-000000000003");
// --- 1. Single resolved name + personRole=sender ---
@Test
void search_resolvesSingleName_asSender() {
Person walter = person(P1, "Walter", "Raddatz");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
NlSearchResponse resp = service.search("Was hat Walter geschrieben?", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
assertThat(cap.getValue().sender()).isEqualTo(P1);
assertThat(cap.getValue().receiver()).isNull();
assertThat(resp.interpretation().resolvedPersons()).hasSize(1);
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
}
// --- 2. Multi-match name → ambiguous, search NOT executed ---
@Test
void search_multiMatchName_populatesAmbiguous_andSkipsSearch() {
Person a = person(UUID.randomUUID(), "Walter", "Braun");
Person b = person(UUID.randomUUID(), "Walter", "Schmidt");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(a, b)));
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
assertThat(resp.interpretation().resolvedPersons()).isEmpty();
}
// --- 3. Multi-match + personRole=any → still ambiguous, search NOT executed ---
@Test
void search_multiMatchName_withPersonRoleAny_stillSkipsSearch() {
Person a = person(UUID.randomUUID(), "Emma", "Braun");
Person b = person(UUID.randomUUID(), "Emma", "Raddatz");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Emma"), "any", null, null, List.of()));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(a, b)));
NlSearchResponse resp = service.search("Briefe an Emma", PAGE);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
verify(documentService, never()).searchDocumentsByPersonId(any(), any(), any(), any());
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
}
// --- 4. No-match name → folded into text ---
@Test
void search_noMatchName_isFoldedIntoText() {
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Karl"), "any", null, null, List.of()));
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
service.search("Briefe von Karl", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).contains("Karl");
assertThat(cap.getValue().sender()).isNull();
assertThat(cap.getValue().receiver()).isNull();
}
// --- 5. personRole=any + 1 resolved → searchDocumentsByPersonId called ---
@Test
void search_personRoleAny_singleMatch_callsSearchDocumentsByPersonId() {
Person walter = person(P1, "Walter", "Raddatz");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
assertThat(resp.interpretation().keywordsApplied()).isFalse();
}
// --- 6. 2 names both resolve → sender=person1, receiver=person2 ---
@Test
void search_twoNamesResolve_assignsSenderAndReceiver() {
Person walter = person(P1, "Walter", "Raddatz");
Person emma = person(P2, "Emma", "Raddatz");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Walter", "Emma"), "any", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
assertThat(cap.getValue().sender()).isEqualTo(P1);
assertThat(cap.getValue().receiver()).isEqualTo(P2);
assertThat(resp.interpretation().resolvedPersons().get(0).id()).isEqualTo(P1);
assertThat(resp.interpretation().resolvedPersons().get(1).id()).isEqualTo(P2);
}
// --- 7. 2 names, first resolves, second ambiguous → search NOT executed ---
@Test
void search_twoNames_secondAmbiguous_skipsSearch() {
Person walter = person(P1, "Walter", "Raddatz");
Person emma1 = person(P2, "Emma", "Braun");
Person emma2 = person(P3, "Emma", "Schmidt");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Walter", "Emma"), "sender", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma1, emma2)));
NlSearchResponse resp = service.search("Briefe von Walter an Emma", PAGE);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
}
// --- 8. 2 names, first no match → folded into text, second used as single person ---
@Test
void search_twoNames_firstNoMatch_secondResolved_foldFirstIntoText() {
Person emma = person(P2, "Emma", "Raddatz");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Karl", "Emma"), "sender", null, null, List.of()));
when(personService.resolveByName("Karl")).thenReturn(makeNameMatches());
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
service.search("Briefe von Karl an Emma", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).contains("Karl");
assertThat(cap.getValue().sender()).isEqualTo(P2);
}
// --- 9. 3+ names all resolve → first two as sender/receiver, third folded into text ---
@Test
void search_threeNamesResolve_extraFoldedIntoText() {
Person walter = person(P1, "Walter", "Raddatz");
Person emma = person(P2, "Emma", "Raddatz");
Person heinrich = person(P3, "Heinrich", "Braun");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Walter", "Emma", "Heinrich"), "any", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
when(personService.resolveByName("Heinrich")).thenReturn(makeNameMatches(List.of(heinrich)));
service.search("Briefe von Walter an Emma über Heinrich", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().sender()).isEqualTo(P1);
assertThat(cap.getValue().receiver()).isEqualTo(P2);
assertThat(cap.getValue().text()).contains("Heinrich");
}
// --- 10. Keywords space-joined into text ---
@Test
void search_keywords_areJoinedIntoText() {
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg", "Walter")));
service.search("Dokumente über den Krieg Walter", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).isEqualTo("Krieg Walter");
}
// --- 11. Date range passed through ---
@Test
void search_dateRange_passedIntoSearchFilters() {
LocalDate from = LocalDate.of(1914, 1, 1);
LocalDate to = LocalDate.of(1914, 12, 31);
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", from, to, List.of()));
service.search("Briefe aus dem Jahr 1914", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().from()).isEqualTo(from);
assertThat(cap.getValue().to()).isEqualTo(to);
}
// --- 12. Null dates → null in SearchFilters (not an error) ---
@Test
void search_nullDates_passedAsNullIntoFilters() {
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
service.search("Hochzeitsbriefe", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().from()).isNull();
assertThat(cap.getValue().to()).isNull();
}
// --- 13. Query under 3 chars → VALIDATION_ERROR before Ollama call ---
@Test
void search_queryTooShort_throwsValidationError() {
assertThatThrownBy(() -> service.search("ab", PAGE))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(ollamaClient, never()).parse(anyString());
}
// --- 14. Query over 500 chars → VALIDATION_ERROR ---
@Test
void search_queryTooLong_throwsValidationError() {
String longQuery = "a".repeat(501);
assertThatThrownBy(() -> service.search(longQuery, PAGE))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.VALIDATION_ERROR);
verify(ollamaClient, never()).parse(anyString());
}
// --- 15. Ollama returns empty names/keywords → raw query used as keyword fallback ---
@Test
void search_ollamaReturnsEmpty_usesRawQueryAsTextFallback() {
String raw = "Briefe aus dem Krieg";
when(ollamaClient.parse(anyString()))
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of(), raw));
service.search(raw, PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).isEqualTo(raw);
}
// --- 16. Null personNames/keywords from Ollama → no NPE ---
@Test
void search_nullPersonNamesAndKeywords_handledWithoutNpe() {
OllamaExtraction ext = new OllamaExtraction(null, "any", null, null, null, "test query");
when(ollamaClient.parse(anyString())).thenReturn(ext);
NlSearchResponse resp = service.search("test query", PAGE);
assertThat(resp).isNotNull();
verify(documentService).searchDocuments(any(), any(), any(), any());
}
// --- 17. Unrecognized personRole → defaults to any-like behavior (no crash) ---
@Test
void search_unrecognizedPersonRole_treatedLikeAny_withSingleResolvedPerson() {
Person walter = person(P1, "Walter", "Raddatz");
// OllamaClient defensive parsing returns "any" for unknown roles,
// but NlQueryParserService must also be safe if something unexpected arrives.
when(ollamaClient.parse(anyString()))
.thenReturn(new OllamaExtraction(List.of("Walter"), "unknown_role", null, null, List.of(), "query"));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
// Should not crash; "unknown_role" treated as fallback (neither sender nor receiver → any)
assertThat(resp).isNotNull();
}
// --- 18. Ollama throws SMART_SEARCH_UNAVAILABLE → propagates to caller ---
@Test
void search_ollamaThrowsUnavailable_propagates() {
when(ollamaClient.parse(anyString()))
.thenThrow(DomainException.tooManyRequests(ErrorCode.SMART_SEARCH_UNAVAILABLE, "offline"));
assertThatThrownBy(() -> service.search("Was hat Walter geschrieben?", PAGE))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
}
// --- 19. LLM-extracted name > 200 chars → skipped, PersonService never called ---
@Test
void search_nameLongerThan200Chars_isSkippedBeforePersonServiceCall() {
String longName = "A".repeat(201);
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(longName), "sender", null, null, List.of()));
service.search("Briefe von sehr langem Namen", PAGE);
verify(personService, never()).resolveByName(anyString());
}
// --- 20. Cap lives in resolveByName (after classification): a pre-capped 10-direct result
// maps straight to ambiguousPersons; the search layer adds no second cap. ---
@Test
void search_tenDirectMatches_allShownAsAmbiguous() {
List<Person> ten = new ArrayList<>();
for (int i = 0; i < 10; i++) {
ten.add(person(UUID.randomUUID(), "Walter", "Person" + i));
}
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(ten));
NlSearchResponse resp = service.search("Briefe von Walter", PAGE);
assertThat(resp.interpretation().ambiguousPersons()).hasSize(10);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
}
// --- 21. SearchFilters defaults: tagOperator=AND, status=null, undated=false, tags=empty ---
@Test
void search_searchFiltersDefaults_areCorrect() {
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Krieg")));
service.search("Dokumente über den Krieg", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), eq(DocumentSort.DATE), eq("desc"), eq(PAGE));
SearchFilters f = cap.getValue();
assertThat(f.tagOperator()).isEqualTo(TagOperator.AND);
assertThat(f.status()).isNull();
assertThat(f.undated()).isFalse();
assertThat(f.tags()).isEmpty();
assertThat(f.tagQ()).isNull();
}
// --- 22. personRole=receiver + 1 resolved → receiver UUID set ---
@Test
void search_personRoleReceiver_singleMatch_setsReceiver() {
Person emma = person(P2, "Emma", "Raddatz");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Emma"), "receiver", null, null, List.of()));
when(personService.resolveByName("Emma")).thenReturn(makeNameMatches(List.of(emma)));
service.search("Briefe an Emma", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().receiver()).isEqualTo(P2);
assertThat(cap.getValue().sender()).isNull();
}
// --- 23. keywordsApplied=true when text is non-blank ---
@Test
void search_keywordsApplied_trueWhenTextNonBlank() {
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
NlSearchResponse resp = service.search("Feldpost aus dem Krieg", PAGE);
assertThat(resp.interpretation().keywordsApplied()).isTrue();
}
// --- 23a. Partial-only, one candidate → ambiguous (1-item picker), search skipped ---
@Test
void search_partialOnly_oneCandidate_populatesAmbiguous() {
Person cramer = person(P1, "Clara", "Cramer");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(), List.of(cramer)));
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
assertThat(resp.interpretation().ambiguousPersons()).hasSize(1);
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
}
// --- 23b. Partial-only, two candidates → ambiguous (multi-item picker) ---
@Test
void search_partialOnly_twoCandidates_populatesAmbiguous() {
Person cramer = person(P1, "Clara", "Cramer");
Person crammond = person(P2, "Clara", "Crammond");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
when(personService.resolveByName("Clara Cram"))
.thenReturn(makeNameMatches(List.of(), List.of(cramer, crammond)));
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
assertThat(resp.interpretation().ambiguousPersons()).hasSize(2);
}
// --- 23c. Exactly one direct match → search executes, no picker ---
@Test
void search_oneDirect_executesSearch() {
Person clara = person(P1, "Clara", "Cram");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Clara Cram"), "any", null, null, List.of()));
when(personService.resolveByName("Clara Cram")).thenReturn(makeNameMatches(List.of(clara)));
NlSearchResponse resp = service.search("Briefe von Clara Cram", PAGE);
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
assertThat(resp.interpretation().ambiguousPersons()).isEmpty();
}
// --- Tag resolution helpers ---
private Tag tag(UUID id, String name) {
return Tag.builder().id(id).name(name).build();
}
private Tag tag(UUID id, String name, String color) {
return Tag.builder().id(id).name(name).color(color).build();
}
private TagHint tagHint(UUID id, String name, String color) {
return new TagHint(id, name, color);
}
private static final UUID T1 = UUID.fromString("00000000-0000-0000-0001-000000000001");
// --- 24. Single keyword resolves to one tag → tag filter applied ---
@Test
void search_singleKeywordResolvesToTag_appliesTagFilter() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
assertThat(resp.interpretation().tagsApplied()).isTrue();
assertThat(cap.getValue().text()).isNull();
}
private static final UUID T2 = UUID.fromString("00000000-0000-0000-0001-000000000002");
// --- 25. Keyword matches multiple tags → all in resolvedTags, OR-union ---
@Test
void search_keywordMatchesMultipleTags_allIncluded() {
Tag hochzeit1 = tag(T1, "Hochzeit Raddatz");
Tag hochzeit2 = tag(T2, "Hochzeit Braun");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit1, hochzeit2));
NlSearchResponse resp = service.search("Briefe über Hochzeit", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).containsExactlyInAnyOrder("Hochzeit Raddatz", "Hochzeit Braun");
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
assertThat(resp.interpretation().resolvedTags()).hasSize(2);
}
// --- 26. Keyword no tag match → stays as FTS text, resolvedTags empty ---
@Test
void search_keywordNoTagMatch_staysAsFtsText() {
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Feldpost")));
NlSearchResponse resp = service.search("Feldpost Briefe", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).contains("Feldpost");
assertThat(cap.getValue().tags()).isEmpty();
assertThat(resp.interpretation().resolvedTags()).isEmpty();
assertThat(resp.interpretation().tagsApplied()).isFalse();
}
// --- 27. Mixed: one keyword resolves, one doesn't → tag filter + FTS text ---
@Test
void search_mixedKeywords_oneResolves_oneStaysAsText() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "Feldpost")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit und Feldpost", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
assertThat(cap.getValue().tagOperator()).isEqualTo(TagOperator.OR);
assertThat(cap.getValue().text()).contains("Feldpost");
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
assertThat(resp.interpretation().tagsApplied()).isTrue();
}
// --- 28. personRole=any + 1 person + resolvable keyword → personId search, tagsApplied=false ---
@Test
void search_personRoleAny_singlePerson_resolvableKeyword_tagsAppliedFalse() {
Person walter = person(P1, "Walter", "Raddatz");
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of("Walter"), "any", null, null, List.of("Hochzeit")));
when(personService.resolveByName("Walter")).thenReturn(makeNameMatches(List.of(walter)));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Briefe von Walter über Hochzeit", PAGE);
verify(documentService).searchDocumentsByPersonId(eq(P1), isNull(), isNull(), eq(PAGE));
verify(documentService, never()).searchDocuments(any(), any(), any(), any());
assertThat(resp.interpretation().tagsApplied()).isFalse();
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
assertThat(resp.interpretation().resolvedTags().get(0).name()).isEqualTo("Hochzeit");
}
// --- 29. Cap: keyword matches > 10 tags → capped at 10 ---
@Test
void search_keywordMatchesMoreThanMaxTags_cappedAtTen() {
List<Tag> eleven = new ArrayList<>();
for (int i = 0; i < 11; i++) {
eleven.add(tag(UUID.randomUUID(), "Thema " + i));
}
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Thema")));
when(tagService.findByNameContaining("Thema")).thenReturn(eleven);
NlSearchResponse resp = service.search("Dokumente zum Thema", PAGE);
assertThat(resp.interpretation().resolvedTags()).hasSize(10);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).hasSize(10);
}
// --- 30. Short keyword (< 3 chars) → skipped, not passed to TagService ---
@Test
void search_shortKeyword_skippedByTagResolution() {
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("ab", "Krieg")));
service.search("ab Krieg", PAGE);
verify(tagService, never()).findByNameContaining("ab");
verify(tagService).findByNameContaining("Krieg");
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).contains("ab");
}
// --- 31. Dedup: same tag matched by two keywords → appears once ---
@Test
void search_sameTagMatchedByTwoKeywords_deduplicatedToOne() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit", "hoch")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
when(tagService.findByNameContaining("hoch")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit hoch", PAGE);
assertThat(resp.interpretation().resolvedTags()).hasSize(1);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().tags()).hasSize(1);
}
// --- 32. All keywords resolve → rawQuery fallback suppressed, text=null ---
@Test
void search_allKeywordsResolveToTags_rawQueryFallbackSuppressed() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
.thenReturn(new OllamaExtraction(List.of(), "any", null, null, List.of("Hochzeit"), "raw query text"));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit", PAGE);
ArgumentCaptor<SearchFilters> cap = ArgumentCaptor.forClass(SearchFilters.class);
verify(documentService).searchDocuments(cap.capture(), any(), any(), any());
assertThat(cap.getValue().text()).isNull();
assertThat(cap.getValue().tags()).containsExactly("Hochzeit");
}
// --- 33. Flag independence: keywordsApplied=false AND tagsApplied=true ---
@Test
void search_allKeywordsResolveToTags_keywordsAppliedFalse_tagsAppliedTrue() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit Briefe", PAGE);
assertThat(resp.interpretation().keywordsApplied()).isFalse();
assertThat(resp.interpretation().tagsApplied()).isTrue();
}
// --- 34. Color carried through from resolveEffectiveColors ---
@Test
void search_tagHint_carriesColorSetByResolveEffectiveColors() {
Tag hochzeit = tag(T1, "Hochzeit");
doAnswer(invocation -> {
Collection<Tag> tags = invocation.getArgument(0);
tags.forEach(t -> t.setColor("sage"));
return null;
}).when(tagService).resolveEffectiveColors(any());
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit", PAGE);
assertThat(resp.interpretation().resolvedTags().get(0).color()).isEqualTo("sage");
}
// --- 35. Color stays null when resolveEffectiveColors leaves it unset ---
@Test
void search_tagHint_colorIsNull_whenNoColorResolved() {
Tag hochzeit = tag(T1, "Hochzeit");
when(ollamaClient.parse(anyString()))
.thenReturn(extraction(List.of(), "any", null, null, List.of("Hochzeit")));
when(tagService.findByNameContaining("Hochzeit")).thenReturn(List.of(hochzeit));
NlSearchResponse resp = service.search("Hochzeit", PAGE);
assertThat(resp.interpretation().resolvedTags().get(0).color()).isNull();
}
}

View File

@@ -1,161 +0,0 @@
package org.raddatz.familienarchiv.search;
import tools.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.document.DocumentSearchResult;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.security.SecurityConfig;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.*;
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.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(NlSearchController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class,
NlSearchRateLimiter.class, NlSearchRateLimitProperties.class})
class NlSearchControllerTest {
@Autowired MockMvc mockMvc;
private final ObjectMapper objectMapper = new ObjectMapper();
@MockitoBean NlQueryParserService nlQueryParserService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Autowired NlSearchRateLimiter rateLimiter;
@BeforeEach
void resetRateLimiter() {
rateLimiter.resetForTest();
}
private NlSearchResponse makeResponse() {
PersonHint hint = new PersonHint(UUID.randomUUID(), "Walter Raddatz");
NlQueryInterpretation interp = new NlQueryInterpretation(
List.of(hint), List.of(), null, null,
List.of("Krieg"), List.of(), "Briefe von Walter im Krieg", true, false);
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
}
// --- 1. Happy path ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns200_withNlSearchResponse() throws Exception {
when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse());
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter im Krieg\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.interpretation.rawQuery").value("Briefe von Walter im Krieg"))
.andExpect(jsonPath("$.interpretation.resolvedPersons[0].displayName").value("Walter Raddatz"))
.andExpect(jsonPath("$.interpretation.keywordsApplied").value(true));
}
// --- 2. ambiguousPersons in response shape ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns200_withAmbiguousPersons() throws Exception {
PersonHint a = new PersonHint(UUID.randomUUID(), "Walter Braun");
PersonHint b = new PersonHint(UUID.randomUUID(), "Walter Schmidt");
NlQueryInterpretation interp = new NlQueryInterpretation(
List.of(), List.of(a, b), null, null,
List.of(), List.of(), "Briefe von Walter", false, false);
NlSearchResponse resp = new NlSearchResponse(DocumentSearchResult.of(List.of()), interp);
when(nlQueryParserService.search(anyString(), any())).thenReturn(resp);
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.interpretation.ambiguousPersons").isArray())
.andExpect(jsonPath("$.interpretation.ambiguousPersons[0].displayName").value("Walter Braun"))
.andExpect(jsonPath("$.interpretation.ambiguousPersons[1].id").isNotEmpty());
}
// --- 3. Unauthenticated → 401 ---
@Test
void search_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\"}"))
.andExpect(status().isUnauthorized());
}
// --- 4. Query < 3 chars → 400 ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns400_whenQueryTooShort() throws Exception {
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"ab\"}"))
.andExpect(status().isBadRequest());
}
// --- 5. Query > 500 chars → 400 ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns400_whenQueryTooLong() throws Exception {
String longQuery = "a".repeat(501);
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"" + longQuery + "\"}"))
.andExpect(status().isBadRequest());
}
// --- 6. Ollama unavailable → 503 ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns503_whenOllamaUnavailable() throws Exception {
when(nlQueryParserService.search(anyString(), any()))
.thenThrow(DomainException.serviceUnavailable(ErrorCode.SMART_SEARCH_UNAVAILABLE, "Ollama offline"));
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\"}"))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value("SMART_SEARCH_UNAVAILABLE"));
}
// --- 7. 6th request in 1 minute → 429 ---
@Test
@WithMockUser(username = "user@test.com", authorities = {"READ_ALL"})
void search_returns429_onSixthRequestWithinRateLimit() throws Exception {
when(nlQueryParserService.search(anyString(), any())).thenReturn(makeResponse());
for (int i = 0; i < 5; i++) {
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\"}"))
.andExpect(status().isOk());
}
mockMvc.perform(post("/api/search/nl").with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"query\":\"Briefe von Walter\"}"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value("SMART_SEARCH_RATE_LIMITED"));
}
}

View File

@@ -1,62 +0,0 @@
package org.raddatz.familienarchiv.search;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class NlSearchRateLimiterTest {
private NlSearchRateLimiter rateLimiter;
@BeforeEach
void setUp() {
NlSearchRateLimitProperties props = new NlSearchRateLimitProperties();
props.setMaxRequestsPerMinute(5);
rateLimiter = new NlSearchRateLimiter(props);
}
@Test
void checkAndConsume_allowsRequestsWithinLimit() {
for (int i = 0; i < 5; i++) {
assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com"))
.doesNotThrowAnyException();
}
}
@Test
void checkAndConsume_throwsRateLimited_onSixthRequest() {
for (int i = 0; i < 5; i++) {
rateLimiter.checkAndConsume("user@example.com");
}
assertThatThrownBy(() -> rateLimiter.checkAndConsume("user@example.com"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_RATE_LIMITED);
}
@Test
void checkAndConsume_limitsAreIndependentPerUser() {
for (int i = 0; i < 5; i++) {
rateLimiter.checkAndConsume("alice@example.com");
}
assertThatCode(() -> rateLimiter.checkAndConsume("bob@example.com"))
.doesNotThrowAnyException();
}
@Test
void resetForTest_clearsAllBuckets() {
for (int i = 0; i < 5; i++) {
rateLimiter.checkAndConsume("user@example.com");
}
rateLimiter.resetForTest();
assertThatCode(() -> rateLimiter.checkAndConsume("user@example.com"))
.doesNotThrowAnyException();
}
}

View File

@@ -1,56 +0,0 @@
package org.raddatz.familienarchiv.search;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.tag.Tag;
import org.raddatz.familienarchiv.tag.TagRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.annotation.Import;
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 NlSearchTagResolutionIntegrationTest {
@Autowired
private TagRepository tagRepository;
@Test
void findDescendantIdsByName_parentName_includesChildId() {
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
List<UUID> ids = tagRepository.findDescendantIdsByName("Krieg");
assertThat(ids).containsExactlyInAnyOrder(parent.getId(), child.getId());
}
@Test
void findDescendantIdsByName_childName_returnsOnlyChild() {
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
Tag child = tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
List<UUID> ids = tagRepository.findDescendantIdsByName("Weltkrieg");
assertThat(ids).containsExactly(child.getId());
assertThat(ids).doesNotContain(parent.getId());
}
@Test
void findByNameContainingIgnoreCase_parentSubstring_matchesParentOnly() {
Tag parent = tagRepository.save(Tag.builder().name("Krieg").build());
tagRepository.save(Tag.builder().name("Weltkrieg").parentId(parent.getId()).build());
List<Tag> found = tagRepository.findByNameContainingIgnoreCase("Krieg");
assertThat(found).extracting(Tag::getName).containsExactlyInAnyOrder("Krieg", "Weltkrieg");
}
}

View File

@@ -1,113 +0,0 @@
package org.raddatz.familienarchiv.search;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RestClientOllamaClientTest {
private WireMockServer wireMock;
private RestClientOllamaClient client;
@BeforeEach
void setUp() {
wireMock = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
wireMock.start();
OllamaProperties props = new OllamaProperties();
props.setBaseUrl("http://localhost:" + wireMock.port());
props.setModel("qwen2.5:7b-instruct-q4_K_M");
props.setTimeoutSeconds(5);
props.setHealthCheckTimeoutSeconds(2);
client = new RestClientOllamaClient(props);
}
@AfterEach
void tearDown() {
wireMock.stop();
}
// --- Factory helpers ---
private String makeOllamaResponseJson(String personNamesJson, String personRole,
String dateFrom, String dateTo, String keywordsJson) {
String inner = String.format(
"{\"personNames\":%s,\"personRole\":\"%s\",\"dateFrom\":%s,\"dateTo\":%s,\"keywords\":%s}",
personNamesJson, personRole,
dateFrom == null ? "null" : "\"" + dateFrom + "\"",
dateTo == null ? "null" : "\"" + dateTo + "\"",
keywordsJson
);
return String.format("{\"model\":\"qwen2.5:7b-instruct-q4_K_M\",\"response\":\"%s\",\"done\":true}",
inner.replace("\"", "\\\""));
}
// --- Test cases ---
@Test
void parse_returnsExtraction_whenOllamaReturnsValidJson() {
String body = makeOllamaResponseJson("[\"Walter\"]", "sender", "1914-01-01", "1914-12-31", "[\"Krieg\"]");
wireMock.stubFor(post(urlEqualTo("/api/generate"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody(body)));
OllamaExtraction result = client.parse("Was hat Walter im Krieg geschrieben?");
assertThat(result.personNames()).containsExactly("Walter");
assertThat(result.personRole()).isEqualTo("sender");
assertThat(result.keywords()).containsExactly("Krieg");
assertThat(result.dateFrom()).isNotNull();
assertThat(result.dateTo()).isNotNull();
}
@Test
void parse_throwsSmartSearchUnavailable_whenOllamaReturns500() {
wireMock.stubFor(post(urlEqualTo("/api/generate"))
.willReturn(aResponse().withStatus(500)));
assertThatThrownBy(() -> client.parse("some query"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
}
@Test
void parse_throwsSmartSearchUnavailable_whenOllamaExceedsTimeout() {
wireMock.stubFor(post(urlEqualTo("/api/generate"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withFixedDelay(6000)
.withBody("{\"response\":\"{}\",\"done\":true}")));
assertThatThrownBy(() -> client.parse("some query"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
}
@Test
void parse_throwsSmartSearchUnavailable_whenOllamaReturnsMalformedJson() {
wireMock.stubFor(post(urlEqualTo("/api/generate"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"response\":\"not-json-at-all\",\"done\":true}")));
assertThatThrownBy(() -> client.parse("some query"))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode())
.isEqualTo(ErrorCode.SMART_SEARCH_UNAVAILABLE);
}
}

View File

@@ -50,7 +50,6 @@ volumes:
minio-data:
ocr-models:
ocr-cache:
ollama-models:
services:
db:
@@ -201,73 +200,6 @@ services:
security_opt:
- no-new-privileges:true
# --- Ollama: Model init (one-shot pull) ---
# Pulls qwen2.5:7b-instruct-q4_K_M (~4.7 GB) into the ollama-models volume on
# first start; exits quickly on subsequent starts (model already cached).
# The ollama/ollama image's ENTRYPOINT is `ollama` and the image ships WITHOUT
# curl, so the entrypoint is overridden to a shell and readiness is probed with
# `ollama list` (not curl). The pull is guarded by a `grep` on the cached model
# list so a model already on the volume exits clean WITHOUT a registry round-trip
# — a host reboot during a registry/network blip can no longer fail init (which
# would block the ollama service via service_completed_successfully).
# Backend degrades gracefully (503) if Ollama is absent.
ollama-model-init:
image: ollama/ollama:0.30.6
restart: "no"
entrypoint: ["/bin/sh", "-c"]
command:
- "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)"
networks:
- archiv-net
volumes:
- ollama-models:/root/.ollama
mem_limit: 2g
read_only: true
tmpfs:
- /tmp:size=512m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# --- Ollama: LLM inference server ---
# Serves the pre-pulled model for NL search inference. Backend reaches it at
# http://ollama:11434 (application.yaml default; no env override required).
# Healthcheck uses `ollama list` because the image has no curl.
ollama:
image: ollama/ollama:0.30.6
restart: unless-stopped
expose:
- "11434"
networks:
- archiv-net
volumes:
- ollama-models:/root/.ollama
environment:
# Pin the model in memory (no idle unload). Without this, Ollama evicts
# the model after ~5 min idle and the next query pays a cold-load penalty
# that exceeds the backend read timeout → NL search 503 after idle.
OLLAMA_KEEP_ALIVE: "-1"
cpus: "${OLLAMA_CPU_LIMIT:-4.0}"
mem_limit: "${OLLAMA_MEM_LIMIT:-8g}"
memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}"
read_only: true
tmpfs:
- /tmp:size=512m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
depends_on:
ollama-model-init:
condition: service_completed_successfully
backend:
image: familienarchiv/backend:${TAG:-nightly}
build:

View File

@@ -141,75 +141,6 @@ services:
security_opt:
- no-new-privileges:true
# --- Ollama: Model init (one-shot pull) ---
# Pulls qwen2.5:7b-instruct-q4_K_M (~4.7 GB) into the ollama_models volume on first start.
# On subsequent starts (model already in volume), exits quickly without re-downloading.
# Not started in CI — CI uses explicit service selection
# (docker-compose.ci.yml: db minio create-buckets)
ollama-model-init:
image: ollama/ollama:0.30.6
restart: "no"
networks:
- archiv-net
volumes:
- ollama_models:/root/.ollama
mem_limit: 2g
read_only: true
tmpfs:
- /tmp:size=512m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# The image ENTRYPOINT is `ollama`, so override it to a shell; the image has
# no curl, so readiness is probed with `ollama list` instead of a curl loop.
# The pull is guarded by a `grep` on the cached model list so an already-cached
# model exits clean without a registry round-trip (offline-safe re-up).
entrypoint: ["/bin/sh", "-c"]
command:
- "ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && (ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)"
# --- Ollama: LLM inference server ---
# Serves the pre-pulled model for NL search inference.
# Not started in CI — CI uses explicit service selection
# (docker-compose.ci.yml: db minio create-buckets)
ollama:
image: ollama/ollama:0.30.6
container_name: archive-ollama
restart: unless-stopped
expose:
- "11434"
networks:
- archiv-net
volumes:
- ollama_models:/root/.ollama
environment:
OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
# Pin the model in memory (no idle unload) so queries never pay a cold-load
# penalty that exceeds the backend read timeout → NL search 503 after idle.
OLLAMA_KEEP_ALIVE: "-1"
cpus: "${OLLAMA_CPU_LIMIT:-4.0}"
mem_limit: "${OLLAMA_MEM_LIMIT:-8g}"
memswap_limit: "${OLLAMA_MEM_LIMIT:-8g}"
read_only: true
tmpfs:
- /tmp:size=512m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
healthcheck:
# `ollama list` hits the local API and exits non-zero if the server is
# down — used instead of curl, which the image does not ship.
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s # model weights are pre-loaded by ollama-model-init; service only needs to bind port
depends_on:
ollama-model-init:
condition: service_completed_successfully
# --- Backend: Spring Boot ---
backend:
build:
@@ -253,8 +184,6 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${MAIL_STARTTLS_ENABLE:-false}
APP_OCR_BASE_URL: http://ocr-service:8000
APP_OCR_TRAINING_TOKEN: "${OCR_TRAINING_TOKEN:-}"
APP_OLLAMA_BASE_URL: "${APP_OLLAMA_BASE_URL:-http://ollama:11434}"
APP_OLLAMA_API_KEY: "${OLLAMA_API_KEY}"
SENTRY_DSN: ${SENTRY_DSN:-}
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-1.0}
# Observability: send traces to Tempo inside archiv-net (OTLP gRPC port 4317)
@@ -318,4 +247,3 @@ volumes:
frontend_node_modules:
ocr_models:
ocr_cache:
ollama_models:

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` (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 |

View File

@@ -50,17 +50,15 @@ graph TD
The OCR service requires significant RAM for model loading. The dev compose sets `mem_limit: 12g`.
| Production target | RAM | Recommended OCR limit | NL Search | Notes |
|---|---|---|---|---|
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Supported | Default `mem_limit: 12g` works comfortably; plenty of headroom for Ollama |
| ≥ 16 GB RAM | 16+ GB | 12 GB | Supported | Default works |
| 8 GB RAM | 8 GB | 6 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
| 4 GB RAM | 4 GB | — | Unsupported | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
| Production target | RAM | Recommended OCR limit | Notes |
|---|---|---|---|
| Current server (Hetzner Serverbörse, i7-6700) | 64 GB | 12 GB | Default `mem_limit: 12g` works comfortably |
| ≥ 16 GB RAM | 16+ GB | 12 GB | Default works |
| 8 GB RAM | 8 GB | 6 GB | Set `OCR_MEM_LIMIT=6g`; accept reduced batch sizes |
| 4 GB RAM | 4 GB | — | Disable OCR service (`profiles: [ocr]`); run OCR on demand only |
On servers with less than 16 GB RAM the default `mem_limit: 12g` cannot be honoured — set the `OCR_MEM_LIMIT` env var (in `.env.production` / `.env.staging`, or as a Gitea secret consumed by the workflow). The prod compose interpolates this var with a 12g default.
> **Memory budget:** OCR (~6 GB active) + Ollama (~8 GB) = ~14 GB. On servers with less than 16 GB RAM, do not run `docker-compose.observability.yml` continuously alongside both OCR and Ollama.
### Dev vs production differences
| Concern | Dev (`docker-compose.yml`) | Prod (`docker-compose.prod.yml`) |
@@ -126,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? |
@@ -147,16 +147,6 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
| `XDG_CACHE_HOME` | XDG cache base dir — redirects Matplotlib and other XDG-aware libraries away from the read-only `HOME` (`/home/ocr`) to the writable cache volume | `/app/cache` | — | — |
| `TORCH_HOME` | PyTorch model cache — redirects `~/.cache/torch` to the writable models volume | `/app/models/torch` | — | — |
### Ollama (NL search) service
| Variable | Purpose | Default | Required? | Sensitive? |
|---|---|---|---|---|
| `APP_OLLAMA_BASE_URL` | Base URL for the Ollama service. Leave empty to disable NL search. | `http://ollama:11434` | — | — |
| `APP_OLLAMA_API_KEY` | API key passed as `Authorization: Bearer` to Ollama. Leave empty for unauthenticated access. Note: `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 (see ADR-028). | — | — | YES |
| `OLLAMA_CPU_LIMIT` | Docker CPU quota for the Ollama container. On CX42 (8 vCPUs) can be raised to `7.5`. | `4.0` | — | — |
| `OLLAMA_MEM_LIMIT` | Memory limit for the Ollama container. Requires CX42 (16 GB RAM). | `8g` | — | — |
| `OLLAMA_API_KEY` | API key set on the Ollama service itself. Same value as `APP_OLLAMA_API_KEY`. Leave empty for unauthenticated. | — | — | YES |
### Observability stack (`docker-compose.observability.yml`)
| Variable | Purpose | Default | Required? | Sensitive? |
@@ -277,18 +267,6 @@ git.raddatz.cloud A <server IP>
### 3.4 First deploy
> **First start — Ollama model pull:** On first `docker compose up -d`, the `ollama-model-init` container pulls `qwen2.5:7b-instruct-q4_K_M` (~4.7 GB). At 10 Mbps this takes approximately 6090 minutes; at 100 Mbps approximately 610 minutes. The pull is a one-time operation — subsequent restarts skip it (model already on the `ollama_models` volume). Monitor progress with `docker logs -f $(docker ps -q --filter name=ollama-model-init)`.
>
> **Do not use `--wait` on first deploy** — `docker compose up -d --wait` waits for all services to reach their health/completion target, including `ollama-model-init`. On first pull this blocks for 6090 minutes and will time out any CI/deploy script that uses `--wait`.
>
> **Re-deploy idempotency:** on subsequent `docker compose up -d` runs (including `--force-recreate`), `ollama-model-init` re-executes but exits in seconds — Ollama's CLI skips the download when the model digest already matches what is on the volume.
>
> **Verify NL search is active** after enabling Ollama (`APP_OLLAMA_BASE_URL=http://ollama:11434`):
> ```bash
> curl -s http://localhost:8080/api/nl-search?q=brief+von+grossmutter
> # Returns 200 with results → NL search is active
> # Returns 503 NL_SEARCH_UNAVAILABLE → Ollama is not reachable or APP_OLLAMA_BASE_URL is unset
> ```
```bash
# 1. Trigger nightly.yml manually (Repo → Actions → nightly → "Run workflow")
@@ -585,55 +563,6 @@ bash scripts/download-kraken-models.sh
> Downloads the Kurrent/Sütterlin HTR models. Run once after a fresh clone or when models are updated.
### Ollama — natural-language search (NL Search)
NL search uses a local Ollama instance for query parsing. The `ollama` service is defined in `docker-compose.yml` alongside the main stack.
**First-time model pull** (required before the feature works):
```bash
docker compose exec ollama ollama pull qwen2.5:7b-instruct-q4_K_M
```
This downloads ~4.4 GB. The model is stored in the `ollama_data` Docker volume and persists across container restarts.
**Verify the model is available:**
```bash
docker compose exec ollama ollama list
```
Expected output includes `qwen2.5:7b-instruct-q4_K_M`.
**Health check** — the backend polls `GET /api/tags` on Ollama at startup and before inference. If Ollama is absent, `POST /api/search/nl` returns HTTP 503 with `SMART_SEARCH_UNAVAILABLE`.
**Configuration** (see `application.yaml` under `app.ollama`):
| Property | Default | Description |
|---|---|---|
| `app.ollama.base-url` | `http://ollama:11434` | Ollama service URL (dev: `http://localhost:11434`) |
| `app.ollama.model` | `qwen2.5:7b-instruct-q4_K_M` | Model to use for inference |
| `app.ollama.timeout-seconds` | `60` | Read timeout for inference calls (absorbs cold model load on the first query after an Ollama restart) |
| `app.nl-search.rate-limit.max-requests-per-minute` | `5` | Per-user rate limit |
### Upgrade the Ollama model
To switch to a newer model version (e.g. a future release of `qwen2.5`):
1. Update the model name in the `ollama-model-init` `command:` in `docker-compose.yml`.
2. Remove the existing model volume to free the old weights:
```bash
docker volume rm familienarchiv_ollama_models
```
(In production the volume name is prefixed with the compose project: `archiv-production_ollama-models`.)
3. Restart the stack:
```bash
docker compose up -d
```
The `ollama-model-init` container pulls the new model weights on first start (~48 GB download depending on the model). The `ollama` inference server will not start until the pull completes (`condition: service_completed_successfully`).
> **`ollama_models` volume:** holds model weights only — fully reproducible by re-pull, no backup needed.
### Trigger a canonical import
The importer no longer parses the raw spreadsheet. It consumes the **canonical artifacts**

View File

@@ -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.
@@ -165,23 +178,7 @@ _See also [Chronik](#chronik-internal)._
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
---
## NL Search Terms
**NlSearch** — the natural-language document search feature. Users type a plain-German query (e.g. "Was hat Walter im Krieg an Emma geschrieben?"); the backend parses it via Ollama, resolves person names to database UUIDs, and delegates to the standard `DocumentService.searchDocuments()` path. Endpoint: `POST /api/search/nl`.
**NlQueryInterpretation** — the structured result of parsing a natural-language query. Contains: `resolvedPersons` (persons whose names unambiguously matched one DB record), `ambiguousPersons` (all candidates when a name matched more than one person), `keywords` (LLM-extracted search terms), `dateFrom`/`dateTo` (extracted date range), `rawQuery` (the original user input), `keywordsApplied` (whether keyword FTS was used), `resolvedTags` (tags matched by keyword→tag resolution), and `tagsApplied` (whether the OR-union tag filter was applied).
**keyword→tag resolution** — the post-Ollama step in `NlQueryParserService` where each LLM-extracted keyword is substring-matched against the tag taxonomy via `TagService.findByNameContaining()`. Keywords that hit one or more tags are removed from the FTS text list and become an OR-union tag filter; keywords with no match remain as FTS text. Matching is case-insensitive and traverses the tag hierarchy via the recursive CTE `findDescendantIdsByName`. See ADR-033.
**PersonHint** — a lightweight `{id, displayName}` pair used in `NlQueryInterpretation` to describe a resolved or ambiguous person without exposing the full `Person` entity to the frontend.
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer"). The vocabulary is deliberately match strength, not the search layer's resolved/ambiguous buckets — `NlQueryParserService` maps one direct → resolved (auto-select), ≥2 direct → ambiguous, partial-only → ambiguous suggestions ("Meintest du …?"), and no candidates → folded into full-text search.
**TagHint** — a lightweight `{id, name, color?}` triple used in `NlQueryInterpretation.resolvedTags` to describe a tag matched by keyword→tag resolution. `color` is the tag's effective color (one-level inheritance from parent when the tag has no own color), or null if neither tag nor parent has a color.
**theme chip** `[frontend]` — a removable chip rendered in `InterpretationChipRow` for each entry in `NlQueryInterpretation.resolvedTags` when `tagsApplied` is `true`. Displays "Thema: {tag.name}" (prefix varies by locale). Clicking × removes the tag from the OR-union filter and navigates to `/documents?tag=…&tagOp=OR` with remaining tag and person parameters preserved.
**NameMatches** — the Person-domain result of `PersonService.resolveByName(name)`: candidate persons split by name-match strength into `direct` and `partial`. A match is **direct** when every query token is a whole-token match (order-independent, alias/maiden-name aware) across all of a person's name components (`firstName`, `lastName`, `alias`, each `PersonNameAlias` first+last, `title`); a **partial** matched the substring fetch but is not direct (e.g. "Cram" → "Clara Cramer").
---

View File

@@ -1,67 +0,0 @@
# ADR-028 — Natural language search is powered by Ollama (Qwen 2.5 7B), not a cloud API
**Date:** 2026-06-06
**Status:** Accepted
**Issue:** #738 (NL search backend); part of epic #735
**Milestone:** Archive Intelligence — NL Search
---
## Context
Family members write their search intent in plain German ("Was hat Walter im Krieg an Emma geschrieben?"), not in structured filter forms. Issue #735 defines NL search as a core product goal. Three delivery options were evaluated:
**Option A — extend the OCR service.** The OCR Python microservice already runs on the same host. Adding LLM inference there avoids a new container. Rejected: the OCR service is a single-purpose, CPU-bound pipeline optimised for Kraken; bundling a 4.5 GB LLM weight into the same image would bloat it, complicate model lifecycle management, and create an unrelated failure domain (OOM on large OCR batches vs. LLM load time). ADR-001 was explicit about keeping OCR single-purpose.
**Option B — call an external API (OpenAI, Anthropic, etc.).** Cloud inference is instant and requires no local hardware. Rejected: the archive contains real person names and private family correspondence from 18991950 — sending query content to a third party violates the project's data-residency principle (family data stays on the family server). Additionally, API cost and availability are outside the operator's control; the system must work air-gapped.
**Option C — local Ollama service (chosen).** Ollama is a purpose-built LLM runtime with a simple REST API, model lifecycle management (`ollama pull`), and support for grammar-constrained JSON output. It runs entirely on the existing server (i7-6700, 64 GB RAM) with no cloud dependency.
**Model selection:** Qwen 2.5 7B Q4_K_M (`qwen2.5:7b-instruct-q4_K_M`) was chosen over larger models because:
- Quantised weight is ~4.5 GB — fits comfortably in 64 GB RAM alongside PostgreSQL and the JVM.
- Instruction-tuned variant follows the structured JSON schema reliably without fine-tuning.
- CPU-only inference at Q4_K_M takes 215 seconds per query, acceptable for a search that replaces a multi-step filter form.
**Prompt injection mitigation:** The backend sends the raw user query to Ollama. To prevent the model from being prompted to return schema-breaking output, the API call uses Ollama's `format` parameter with a grammar-constrained JSON schema. Output length is further bounded by `maxLength` constraints in the schema (names ≤ 200 chars, keywords ≤ 100 chars). `NlQueryParserService` enforces these limits in code before any LLM-extracted fragment is passed to `PersonRepository.searchByName()` — defence in depth.
**DB-blind name resolution:** The Ollama prompt stays small (the raw query only); person database records are never sent to the model. Name resolution happens as a cheap SQL query after the model returns. This keeps the prompt short, avoids data leakage, and means adding 1,000 new persons requires no prompt change.
**Graceful degradation:** In-path Ollama failures surface via `OllamaClient.parse()` — any `IOException`, read timeout, or non-2xx response is caught by `RestClientOllamaClient` and re-thrown as `DomainException(SMART_SEARCH_UNAVAILABLE, HTTP 503)`. `isHealthy()` has no callers inside `search/`; it is reserved for the ops/health-endpoint polling path only (e.g. a future `/api/health/ollama` endpoint). The regular structured search (`GET /api/documents/search`) is unaffected — it never calls Ollama.
**Expected inference latency:** 215 seconds on the current CPU-only hardware. The frontend issue must show a persistent "Suche läuft…" indicator for the full duration (see `aria-live="polite"` requirement in issue #738 frontend notes). The backend timeout is 30 seconds (`app.ollama.timeout-seconds=30`) — chosen as a safe upper bound for Q4_K_M on the i7-6700 with a realistic 500-character query under modest concurrent load.
**NL query logging policy:** Only metadata is logged — query length, resolved person count, latency in milliseconds. The raw query is never written to the log file. Rationale: queries contain real family names (PII); log files persist to disk and may be shipped to Loki. Structured metadata is sufficient for debugging latency regressions.
**Prompt-amplification abuse:** A malicious user could submit a long or crafted query to cause slow Ollama inference, consuming CPU. Mitigated by `NlSearchRateLimiter` (5 requests per user per minute, Bucket4j + Caffeine) and by `@Size(max=500)` on the request body. The rate limiter is node-local; in multi-replica deployments the effective limit multiplies by replica count — acceptable at the current single-node deployment scale.
**Ollama model pre-pull requirement:** The Docker image contains only the Ollama binary, not the model weights. The operator must run `ollama pull qwen2.5:7b-instruct-q4_K_M` (≈4.5 GB download, 1030 minutes) before the backend starts inference. If skipped, every NL search request returns 503 until the pull completes. The deployment runbook in `docs/DEPLOYMENT.md` covers this explicitly.
**Startup dependency:** The `backend` Compose service declares `depends_on: ollama: condition: service_healthy`. The Ollama healthcheck polls `GET http://localhost:11434/api/tags`; `start_period: 120s` provides margin for weight loading (2060 s on SSD). Note: `service_healthy` confirms the API is responding, not that the model is downloaded — if the pull was skipped, inference still returns 404.
**Multi-name resolution heuristic:** For 2-name queries (e.g. "Was hat Walter an Emma geschrieben?"), the first extracted name is treated as sender and the second as receiver. Per-name role annotation (e.g. `{name: "Walter", role: "sender"}`) was rejected because it would require a combinatorially complex Ollama schema and the most natural German phrasing strongly implies sender→receiver order. For single-name queries, a `personRole` field (`sender`/`receiver`/`any`) is returned.
**`personRole: "any"` keyword limitation:** When `personRole` is `"any"` and the name resolves to exactly one person, `DocumentService.searchDocumentsByPersonId()` is called (OR semantics: person as sender or receiver). Keyword filtering is not applied on this path — only person identity and date range. `keywordsApplied = false` is returned in the response. Rationale: the JPQL for OR-semantics person queries has no text predicate; adding FTS would require a native query or a separate pass, adding complexity for a case that is already well-narrowed by person identity.
**`search/``person/` + `document/` dependency direction:** `NlQueryParserService` calls `PersonService.findByDisplayNameContaining()` and `DocumentService.searchDocuments()` — both are legitimate cross-domain service calls, not repository leaks. The `search/` package has no JPA entities of its own and never accesses `PersonRepository` or `DocumentRepository` directly.
**Keyword→tag resolution** (issue #743): After Ollama extracts the `keywords` list, `NlQueryParserService` calls `TagService.findByNameContaining()` for each keyword. Keywords that match one or more tags are removed from the FTS text list and added as OR-union tag filters; keywords with no tag match remain as FTS text. Resolved tags are returned to the frontend as `TagHint` objects in `NlQueryInterpretation.resolvedTags` and rendered as removable "Thema: X" chips. The `tagsApplied` flag signals whether the OR-union filter was actually passed to `DocumentService.searchDocuments()` — it is `false` when the `personRole:any` single-person path is taken, because that path has no tag filter slot. See ADR-033 for the tag name resolution and case-collision rules that `TagService.findByNameContaining()` relies on.
## Decision
**Introduce a new `search/` domain package** with a local Ollama integration via `RestClientOllamaClient`. The Ollama service runs as a separate Docker container, reachable only on the internal Docker network (`expose: ["11434"]`, not `ports:`). The backend calls Ollama's `/api/generate` endpoint with grammar-constrained JSON output. Name resolution and document search are performed by existing services after the model returns.
Key component structure:
- `OllamaClient` / `OllamaHealthClient` interfaces — mockable for tests, modelled on `OcrClient`/`OcrHealthClient`
- `RestClientOllamaClient` — two `RestClient` instances (30 s inference, 2 s health-check)
- `NlQueryParserService` — orchestrates Ollama → name resolution → document search
- `NlSearchRateLimiter` — Bucket4j + Caffeine, 5 req/min per user
- `NlSearchController``POST /api/search/nl`, `@RequirePermission(READ_ALL)`
## Consequences
- Family members can query in natural German without learning filter UI. Expected search satisfaction improvement for the 60+ age cohort (primary transcription audience) is significant.
- NL search is unavailable when Ollama is down or the model pull is not complete. The regular search is unaffected. The 503 response includes a CTA directing users to the regular search.
- Operator responsibility: run `ollama pull` on first deploy and after model updates. The backup runbook must exclude `ollama_models` volume (model weights are re-downloadable, not user data).
- Inference takes 215 seconds. The frontend loading indicator is a hard requirement (see issue #738 frontend notes).
- The rate limiter is node-local. At the current single-node deployment scale this is correct. If the service is ever scaled horizontally, the rate limiter must be moved to Redis (same caveat as `LoginRateLimiter`).
- The `search/` package introduces a new cross-domain dependency direction (`search``person`, `search``document`). This is intentional and documented in `docs/architecture/c4/l3-backend-search.puml`.

View File

@@ -1,239 +0,0 @@
# ADR-028: Ollama Docker Compose service for NL search
**Date:** 2026-06-06
**Status:** Accepted
**Deciders:** Marcel Raddatz
**Relates to:** #737 (infrastructure), #735 (NL search epic)
---
## Context
Issue #735 introduces natural-language document search, requiring a local LLM to generate embeddings and/or run inference at query time. The family archive stores personal family history — data privacy is non-negotiable, so cloud-based inference APIs are excluded. The production target is a Hetzner CX42 (16 GB RAM, 8 vCPUs, CPU-only, ~32 EUR/month).
Alternatives considered:
| Option | Reason rejected |
|---|---|
| **llama.cpp** | No HTTP API out of the box; requires custom wrapper; higher ops burden |
| **vLLM** | GPU-first; significant overhead on CPU-only hardware; overkill for this scale |
| **Cloud APIs** (OpenAI, Gemini, etc.) | Vendor lock-in; per-token cost at scale; data leaves the server — unacceptable for a private family archive |
| **Ollama** | Self-contained Docker image; built-in HTTP REST API; actively maintained; CPU-compatible; zero egress |
**Decision:** run Ollama as a Docker Compose service alongside the existing stack.
---
## Decisions
### 1. Hardware minimums and CPU-only constraint
All inference runs on CPU. The target is the Hetzner CX42 (16 GB RAM, 8 vCPUs).
| Tier | RAM | NL search |
|---|---|---|
| CX42 | 16 GB | Supported — full stack including Ollama |
| CX32 | 8 GB | Disabled — set `APP_OLLAMA_BASE_URL=` (empty) to skip Ollama entirely |
| CX22 | 4 GB | Unsupported for NL search |
### 2. Memory budget on CX42
| Component | `mem_limit` | Typical active RSS |
|---|---|---|
| OCR service | 12g (hard ceiling) | ~6 GB |
| Ollama | 8g | ~8 GB |
| **Total** | | **~14 GB active** |
`memswap_limit` on the Ollama service is set to `8g` (matching `mem_limit`) to prevent Linux from swapping model weights into swap under OCR memory pressure. Swapping model weights does not crash the container but silently degrades inference latency. This mirrors the pattern already applied to the OCR service.
**Operational constraint:** do NOT run `docker-compose.observability.yml` continuously alongside both OCR and Ollama on a CX42. The observability stack adds ~2 GB, which leaves no headroom.
### 3. Graceful-degradation contract
`app.ollama.base-url` absent OR blank → Ollama bean NOT registered → NL search returns HTTP 503 with `ErrorCode: NL_SEARCH_UNAVAILABLE`.
This single code path covers all unavailability scenarios: base-url unset, service unreachable, health check failed, and request timeout.
#### Why not `@ConditionalOnProperty`
`@ConditionalOnProperty` registers the bean when the property is present but blank (`APP_OLLAMA_BASE_URL=`). This produces a `RestClient` with an empty base URL that fails at runtime with an opaque error rather than a clean 503.
#### Correct condition expression
```java
@ConditionalOnExpression("!'${app.ollama.base-url:}'.isBlank()")
```
When the property is absent, the placeholder resolves to `''`; `.isBlank()` returns `true`; negation makes the condition `false`; the bean is not registered. Same result for an explicit empty string (`APP_OLLAMA_BASE_URL=`).
### 4. Backend configuration pattern
Use a `@ConfigurationProperties` record, not separate `@Value` injections:
```java
@ConfigurationProperties("app.ollama")
record OllamaProperties(String baseUrl, String apiKey) {}
```
`OllamaProperties` is registered unconditionally — it is a plain value holder with no side effects.
`@ConditionalOnExpression` belongs **only** on `RestClientOllamaClient` (the bean that creates a live network client).
**Deliberate divergence from the OCR pattern:** the OCR service uses `@Value`-with-default because OCR is always-on and `http://ocr-service:8000` is a safe default. Ollama is truly optional — a missing URL means "feature disabled", not "use this default server". There is no safe default Ollama URL.
### 5. Optional<OllamaClient> injection
The NL search service uses constructor injection with `Optional<OllamaClient>`:
```java
private final Optional<OllamaClient> ollamaClient;
```
When empty (bean not registered), the service method returns 503 immediately:
```java
var client = ollamaClient.orElseThrow(
() -> DomainException.internal(ErrorCode.NL_SEARCH_UNAVAILABLE, "Ollama not configured"));
```
Prefer this over `@Autowired(required = false)` with a null check — the null-check pattern is noisy when the service already uses `@RequiredArgsConstructor`.
### 6. Empty API key guard
`RestClientOllamaClient` omits the `Authorization` header entirely when `apiKey` is blank:
```java
if (!apiKey.isBlank()) {
request.header("Authorization", "Bearer " + apiKey);
}
```
Sending `Authorization: Bearer ` (empty token) has undefined or potentially broken behavior depending on the Ollama version. This mirrors the `trainingToken` guard in `RestClientOcrClient.java:107`.
### 7. OLLAMA_API_KEY behavior in Ollama 0.6.5 and 0.30.6
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `OLLAMA_API_KEY` does **not** enforce request authentication in either version.
Test matrix run against `/api/tags`:
| Configuration | No auth header | `Authorization: Bearer ` (empty) | `Authorization: Bearer wrongkey` | `Authorization: Bearer correctkey` |
|---|---|---|---|---|
| `OLLAMA_API_KEY=` (empty) | 200 | 200 | — | — |
| `OLLAMA_API_KEY` unset | 200 | — | — | — |
| `OLLAMA_API_KEY=testkey99` | 200 | 200 | 200 | 200 |
**Finding:** The `OLLAMA_API_KEY` environment variable is not listed in Ollama's startup config dump and does not gate any HTTP request in either tested version. All configurations — empty string, fully unset, and a real key — accept all requests without authentication.
**Practical implication:** `OLLAMA_API_KEY` provides no defense-in-depth in the tested versions. `archiv-net` network isolation is the only effective security control. The env var is retained in the Compose definition and `.env.example` for forward compatibility if Ollama enables enforcement in a future version, but operators must not rely on it for access control.
**Backend guard still valid:** the `RestClientOllamaClient` code-level guard (omit `Authorization` header when `apiKey.isBlank()`) remains correct behavior regardless — it prevents a malformed `Authorization: Bearer ` header from being sent.
### 8. read_only: true feasibility
**Empirically verified (2026-06-06) on both `0.6.5` and `0.30.6`:** `read_only: true` works with Ollama. All three operations — `ollama serve`, `ollama pull qwen2.5:7b-instruct-q4_K_M`, and `ollama list` — succeeded with exit code 0 in both versions.
Test run:
```bash
docker run --rm --read-only \
-v ollama_models:/root/.ollama \
--tmpfs /tmp \
--entrypoint sh ollama/ollama:0.30.6 \
-c "ollama serve & sleep 5 && ollama pull qwen2.5:7b-instruct-q4_K_M && ollama list"
```
**Note:** the entrypoint must be overridden to `sh` for the test command — the container's default entrypoint is `/bin/ollama` and does not accept `sh` as a subcommand. This is a Docker invocation detail; the Compose service definition uses the image's default entrypoint and `command:` override for the init container, which works correctly.
**Result:** `read_only: true` and `tmpfs: - /tmp:size=512m` are applied to both `ollama` and `ollama-model-init`. The `ollama_models` volume handles all persistent writes; no other paths require write access during normal operation.
### 9. Peak RSS of init container during pull
**Empirically verified (2026-06-06):** Peak RSS during `qwen2.5:7b-instruct-q4_K_M` pull was **~108 MiB**.
`docker stats` samples during the pull (15-second intervals):
| Sample | MEM |
|---|---|
| 1 | 54.89 MiB |
| 2 | 66.3 MiB |
| 5 | 97.25 MiB |
| 9 | **107.8 MiB** (peak) |
`mem_limit: 2g` is adequate — the model weights stream directly to the named volume; RSS is dominated by the Ollama server process alone (~100 MB), not the model data. No bump to 4 GB needed.
### 10. Init container pull mechanism
The `ollama-model-init` container uses a curl-based readiness loop with captured PID:
```sh
ollama serve & SERVE_PID=$!
until curl -sf http://localhost:11434/api/tags; do sleep 1; done
ollama pull qwen2.5:7b-instruct-q4_K_M
kill $SERVE_PID
```
`kill %1` (job-control syntax) is unreliable in non-interactive `sh -c` contexts. Capturing the PID via `SERVE_PID=$!` is reliable.
The same endpoint (`/api/tags`) is used for both the init container readiness loop and the main service `healthcheck`.
### 11. start_period: 60s rationale
The model is pre-pulled by `ollama-model-init` before the main service starts (via `condition: service_completed_successfully`). At main service startup, Ollama only loads model weights from the named volume and binds port 11434.
60 seconds is appropriate for this cold-start profile. 300 seconds was considered — that would be appropriate if the service pulled the model itself — but overstates actual startup time when the model is already present on the volume.
### 12. Security threat model
**Primary control:** `archiv-net` network isolation. Ollama has no externally exposed port (`expose:` only, not `ports:`). The Caddyfile must not route any path to the Ollama service.
**Note on `OLLAMA_API_KEY`:** Per §7, `OLLAMA_API_KEY` is not enforced in Ollama 0.6.5 or 0.30.6 and provides no authentication barrier against a compromised backend container. `archiv-net` network isolation is the sole effective security control. The env var is retained for forward compatibility only — do not rely on it for access control.
Both `ollama` and `ollama-model-init` receive the ADR-019 hardening baseline:
```yaml
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
```
### 13. CI exclusion strategy
Docker Compose profiles are not used — they would add developer friction (requiring `--profile ...` for all local dev commands).
CI uses explicit service selection in `docker-compose.ci.yml`:
```bash
docker compose -f docker-compose.ci.yml up -d db minio create-buckets
```
Ollama is simply not listed and is never started in CI. A YAML comment on the `ollama` service block documents this:
```yaml
# Not started in CI — CI uses explicit service selection
# (docker-compose.ci.yml: db minio create-buckets)
```
### 14. ollama_models volume operational note
The `ollama_models` named volume holds model weights only — fully reproducible by re-pull. No backup is needed.
If the volume fills after a model upgrade:
```bash
docker volume rm ollama_models && docker compose up -d
```
The init container re-pulls the model on next startup.
---
## Consequences
### Positive
- NL search runs entirely on-premises; no data leaves the server and no per-token cloud cost.
- Graceful degradation is a first-class concern: smaller or budget-constrained instances can run the app without Ollama with a single env var change.
- The init container pattern keeps model pull out of the critical startup path for the main service, giving accurate healthcheck timings.
- `@ConditionalOnExpression` with a blank-check is more correct than `@ConditionalOnProperty` for optional features with no safe default URL.
### Risks and operational implications
- **Memory pressure:** OCR + Ollama together consume ~14 GB on a 16 GB host. Running the observability stack simultaneously risks OOM kills. Monitor with `docker stats`.
- **CPU inference latency:** `qwen2.5:7b-instruct-q4_K_M` is chosen for CPU viability, but inference on 8 vCPUs will be noticeably slower than GPU-accelerated alternatives. This is acceptable for the family archive use case (low concurrency, not real-time).
- All three empirical TBD items from the original issue spec were resolved — see §7 (OLLAMA_API_KEY not enforced), §8 (`read_only: true` works), §9 (peak RSS ~108 MiB).
- Model upgrades require a `docker volume rm` to free old weights before pulling the replacement. Document this in runbook/DEPLOYMENT.md.

View File

@@ -1,125 +0,0 @@
# ADR-034: Ollama in production — deployment, keep-alive pinning, and corrected init recipe
**Date:** 2026-06-06
**Status:** Accepted
**Deciders:** Marcel Raddatz
**Relates to:** #758 (bug), #759 (fix), #737 (NL search infrastructure)
**Corrects:** ADR-028 §10§11 (init recipe and readiness probe)
---
## Context
ADR-028 introduced Ollama as a Docker Compose service for NL search and documented
its topology, graceful-degradation contract, and memory budget. Two defects survived
that work and only surfaced once NL search reached staging (#758):
1. **Ollama was added only to the dev `docker-compose.yml`.** Staging and production
deploy from the self-contained `docker-compose.prod.yml`, which had no `ollama`
service. The backend defaults to `app.ollama.base-url: http://ollama:11434`, so its
client bean was active and resolved to a non-existent host → `ResourceAccessException`
→ HTTP 503 on every NL search.
2. **The init recipe documented in ADR-028 §10 never worked.** The `ollama/ollama` image
`ENTRYPOINT` is `ollama`, so a bare `command: sh -c "…"` ran as `ollama sh -c "…"`
(`unknown command "sh"`), and the image ships **no curl**, so the curl-based readiness
loop and the curl healthcheck could never pass.
This ADR records the production deployment decision and the corrected operational
contract. It is also the durable record of *why* `OLLAMA_KEEP_ALIVE=-1` is set, so a
future maintainer does not "optimize" it away and reintroduce the cold-load 503.
---
## Decisions
### 1. Ollama is a first-class production service
`docker-compose.prod.yml` now defines `ollama` + `ollama-model-init` + the
`ollama-models` volume, mirroring the dev stack. The graceful-degradation contract from
ADR-028 §3 is preserved: `backend` has **no** hard `depends_on` on `ollama`, so an absent
or unhealthy Ollama still yields a clean 503 rather than blocking backend startup.
### 2. Corrected init recipe (supersedes ADR-028 §10)
The init container overrides the image entrypoint to a shell and probes readiness with
`ollama list` (not curl, which the image lacks):
```sh
ollama serve & until ollama list >/dev/null 2>&1; do sleep 1; done && \
(ollama list | grep -q 'qwen2.5:7b-instruct-q4_K_M' || ollama pull qwen2.5:7b-instruct-q4_K_M)
```
```yaml
entrypoint: ["/bin/sh", "-c"]
```
The pull is **guarded by a grep on the cached model list**. A model already on the volume
exits clean without any registry round-trip. This makes re-up offline-safe: a host reboot
during a registry/network blip can no longer fail init (which, via
`condition: service_completed_successfully`, would otherwise block the `ollama` service
and take NL search down until the registry was reachable again). The same recipe is used
in dev and prod — one mental model.
### 3. Healthcheck uses `ollama list` (supersedes ADR-028 §11 probe)
```yaml
healthcheck:
test: ["CMD", "ollama", "list"]
```
`ollama list` hits the local API and exits non-zero when the server is down — the correct
probe for a curl-less image. The `start_period: 60s` rationale from ADR-028 §11 still holds.
### 4. `OLLAMA_KEEP_ALIVE=-1` — pin the model in memory
```yaml
environment:
OLLAMA_KEEP_ALIVE: "-1"
```
By default Ollama evicts an idle model after ~5 minutes. The next query then pays a
cold-load penalty that exceeds the backend read timeout, producing an NL search 503 after
any idle period. Pinning the model (`-1` = never unload) keeps warm-path latency
predictable (~18 s on CPU). **Do not remove this** without re-introducing the post-idle
cold-load 503.
### 5. Read timeout raised 30 → 60 s
`app.ollama.timeout-seconds` is raised from 30 to 60 (`application.yaml`, mirrored in
`DEPLOYMENT.md`). Warm CPU inference is ~18 s; the higher ceiling absorbs the one cold
model load on the first query after an Ollama (re)start, before §4's pin takes hold.
**Implicit NFR made explicit:** NL search shall return a result or a 503 within 60 s; the
cold-start path immediately after an Ollama restart is the only path that approaches this
ceiling.
### 6. Hard-OOM trade-off (refines ADR-028 §2)
`memswap_limit == mem_limit` (both `${OLLAMA_MEM_LIMIT:-8g}`) disables swap for the
container. Combined with §4's pinned model, a memory-pressure event is a **hard OOM-kill,
not graceful latency degradation**. This is deliberate — swap-thrashing an LLM is worse
than a clean restart — but it means the 8 GB envelope is a real ceiling. `qwen2.5-7B-q4`
plus its KV cache under load sits close enough to 8 GB that this needs a Prometheus
memory alert on the `ollama` container before it bites in production (tracked as
observability follow-up, not in this PR).
---
## Consequences
### Positive
- NL search works on staging/production, not just dev — the actual deploy artifact now
matches the documented architecture.
- Re-up is offline-safe: a cached model never depends on registry reachability.
- The keep-alive pin and timeout ceiling make NL search latency predictable on CPU.
### Risks and operational implications
- **Hard OOM under memory pressure** (§6): a Prometheus alert on `ollama` container memory
is required before this is load-bearing in prod. Tracked as an observability follow-up.
- **Unauthenticated inference** relies entirely on `archiv-net` isolation (ADR-028 §7/§12,
unchanged). Sending an `Authorization` header from `RestClientOllamaClient` is a separate
durable hardening item, tracked outside this PR.
- ADR-028 §10§11 describe a recipe that never functioned; this ADR is the authoritative
init/healthcheck contract going forward.

View File

@@ -0,0 +1,53 @@
# ADR-034 — Remove NL/smart-search (supersedes ADR-028 ×2, ADR-034-ollama, ADR-035)
**Date:** 2026-06-07
**Status:** Accepted
**Issue:** #772
**Supersedes:** ADR-028 (nl-search-ollama), ADR-028 (ollama-docker-compose-service), ADR-034 (ollama-production-deployment-and-keep-alive), ADR-035 (rule-based-nlp-service)
---
## Context
The natural-language search feature ("KI-Suche" / smart search) allowed users to enter
free-form queries like *"Was hat Walter an Emma im Krieg geschrieben?"* and have them
interpreted by an LLM into structured filters (persons, tags, date range, keywords).
The feature went through two major iterations:
1. **Ollama integration** (ADR-028): an `ollama` Docker service running a local LLM
(llama3.2/gemma3) parsed queries via a JSON-mode prompt.
2. **Rule-based NLP service** (ADR-035): after Ollama proved too slow and unreliable on
CPU-only hardware, a Python FastAPI microservice (`nlp-service`, port 8001) replaced
it with deterministic regex + spaCy parsing plus a lightweight LLM call.
Both approaches shared the same fundamental problem: inference on the production server
(Hetzner Serverbörse, no GPU, 64 GB RAM, i7-6700) was too slow to be useful, with
typical query latencies of 1030 seconds. Users got better and faster results from
the existing keyword search with date/person/tag filters.
## Decision
**Remove the NL search feature entirely.** The Python `nlp-service` microservice, the
Spring Boot `search/` package (`NlSearchController`, `NlQueryParserService`,
`RestClientNlpClient`, `NlSearchRateLimiter`, and all supporting classes), the frontend
NL search components (`SmartModeToggle`, `SmartSearchStatus`, `InterpretationChipRow`,
`DisambiguationPicker`), the related Docker Compose services, Prometheus scrape job,
Grafana dashboard, and all i18n keys are removed.
The existing structured search (FTS keyword + person/tag/date/directional filters) is
sufficient for the archive's current audience and search workload.
## Consequences
- **Capability removed:** users can no longer enter free-form natural-language queries.
They must use the structured filter bar (keyword text box + person/tag/date/directional
dropdowns). For documents where these filters are sufficient, there is no regression.
- **Operational simplification:** the Docker Compose stack loses two services
(`nlp-service` and previously `ollama`/`ollama-model-init`). Memory budget on the
production host is freed. No external model weights need to be kept warm.
- **Future reinstatement:** if a GPU-capable host becomes available, re-implementing
server-side LLM inference would be straightforward given the clean separation of the
`NlSearchController` entry point. However, this ADR deliberately avoids leaving dead
infrastructure or stub code in place — start clean if and when that becomes viable.
- **No data or schema change:** only query/endpoint code and Docker services are removed.
The `documents`, `persons`, and `tags` tables and their FTS indexes are untouched.

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

@@ -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`.

View 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.

View 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).

View File

@@ -12,15 +12,13 @@ System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
Container(frontend, "Web Frontend", "SvelteKit / Node adapter / port 3000", "Server-side rendered UI. Handles auth session cookies, document search and viewer, transcription editor, annotation layer, family tree (Stammbaum), stories (Geschichten), activity feed (Chronik), enrichment workflow, and admin panel.")
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty / port 8080", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications. Trusts X-Forwarded-* headers from Caddy.")
Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
Container(ollama, "Ollama LLM Service", "ollama/ollama:0.30.6 / port 11434 (internal only)", "Local LLM inference server for NL search. Runs qwen2.5:7b-instruct-q4_K_M on CPU. Reachable only on the internal Docker network; no external port exposed. Disabled when APP_OLLAMA_BASE_URL is unset or blank.")
' Named volume: ollama_models — model weights, fully reproducible, no backup needed
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.")
ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Backend uses a bucket-scoped service account (archiv-app), not MinIO root.")
Container(mc, "Bucket / Service-Account Init", "MinIO Client (mc)", "One-shot container on startup. Idempotent: creates the archive bucket, the archiv-app service account, and attaches the readwrite policy.")
}
System_Boundary(observability, "Observability Stack (/opt/familienarchiv/docker-compose.observability.yml)") {
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), Ollama (11434 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
Container(prometheus, "Prometheus", "prom/prometheus:v3.4.0", "Scrapes metrics from backend (8081 /actuator/prometheus), OCR service (8000 /metrics), node-exporter, and cAdvisor. Retention: 30 days.")
Container(node_exporter, "Node Exporter", "prom/node-exporter:v1.9.0", "Host-level CPU, memory, disk, and network metrics.")
Container(cadvisor, "cAdvisor", "gcr.io/cadvisor/cadvisor:v0.52.1", "Per-container resource metrics.")
Container(loki, "Loki", "grafana/loki:3.4.2", "Stores log streams from all containers.")
@@ -43,12 +41,11 @@ Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JS
Rel(backend, mail, "Sends notification and password-reset emails (optional)", "SMTP")
Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
Rel(mc, storage, "Bootstraps bucket + service account on startup", "MinIO Client CLI")
Rel(backend, ollama, "NL query parsing (POST /api/generate)", "HTTP / REST / JSON")
Rel(promtail, loki, "Pushes log streams", "HTTP/Loki push API")
Rel(backend, tempo, "Sends distributed traces via OTLP", "HTTP / OTLP / port 4318 (archiv-net)")
Rel(prometheus, backend, "Scrapes JVM + HTTP metrics", "HTTP 8081 /actuator/prometheus")
Rel(prometheus, ocr, "Scrapes OCR + http_* metrics", "HTTP 8000 /metrics")
Rel(prometheus, ollama, "Scrapes LLM request metrics", "HTTP 11434 /metrics")
Rel(grafana, prometheus, "Queries metrics", "HTTP 9090")
Rel(grafana, loki, "Queries logs", "HTTP 3100")
Rel(grafana, tempo, "Queries traces", "HTTP 3200")

View File

@@ -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")

View File

@@ -1,37 +0,0 @@
@startuml
!include <C4/C4_Component>
title Component Diagram: API Backend — NL Search
Container(frontend, "Web Frontend", "SvelteKit")
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
Container(ollama, "Ollama", "ollama/ollama — port 11434 (internal only)")
System_Boundary(backend, "API Backend (Spring Boot)") {
Component(nlCtrl, "NlSearchController", "Spring MVC — POST /api/search/nl", "REST entry point for natural language search. Enforces READ_ALL permission. Uses @AuthenticationPrincipal UserDetails to obtain the caller's email for rate limiting. Delegates to NlQueryParserService and returns NlSearchResponse.")
Component(rateLimiter, "NlSearchRateLimiter", "Spring Service", "Bucket4j + Caffeine LoadingCache keyed on user email. Allows 5 NL search requests per minute per user. Throws DomainException(SMART_SEARCH_RATE_LIMITED / HTTP 429) when the bucket is exhausted. Node-local — same caveat as LoginRateLimiter.")
Component(parserSvc, "NlQueryParserService", "Spring Service", "Orchestrates the full NL search pipeline: (1) validates query length, (2) calls OllamaClient.parse() to extract structured intent, (3) resolves keywords to tags via TagService.findByNameContaining(), (4) resolves each person name via PersonService.findByDisplayNameContaining(), (5) applies multi-name / personRole heuristics, (6) delegates to DocumentService.searchDocuments() or searchDocumentsByPersonId(). Returns NlSearchResponse. Never logs raw query content (PII).")
Component(ollamaClient, "RestClientOllamaClient", "Spring Service — implements OllamaClient + OllamaHealthClient", "HTTP client for the Ollama API. Uses two separate RestClient instances: inference client (30 s read timeout) and health-check client (2 s connect timeout). Calls POST /api/generate with grammar-constrained JSON schema (personNames, personRole, dateFrom, dateTo, keywords). isHealthy() polls GET /api/tags. Null-coalesces absent personNames/keywords to List.of(). Defaults unknown personRole to 'any' with a warning log. Maps timeout/5xx/parse errors to DomainException(SMART_SEARCH_UNAVAILABLE / HTTP 503).")
Component(ollamaProps, "OllamaProperties", "@ConfigurationProperties(\"app.ollama\")", "Config bean: baseUrl, model (qwen2.5:7b-instruct-q4_K_M), timeoutSeconds (default: 30), healthCheckTimeoutSeconds (default: 2).")
Component(rateLimitProps, "NlSearchRateLimitProperties", "@ConfigurationProperties(\"app.nl-search.rate-limit\")", "Config bean: maxRequestsPerMinute (default: 5).")
}
Component(personSvc, "PersonService", "Spring Service", "See diagram 3e. findByDisplayNameContaining(fragment) delegates to PersonRepository.searchByName() — covers first+last name, alias, and name aliases via LEFT JOIN.")
Component(documentSvc, "DocumentService", "Spring Service", "See diagram 3b. searchDocuments() for keyword/sender/receiver/date queries. searchDocumentsByPersonId() for OR-semantics single-person queries (person as sender OR receiver, no keyword filter).")
Component(tagSvc, "TagService", "Spring Service", "See diagram 3b. findByNameContaining(fragment) delegates to TagRepository.findByNameContainingIgnoreCase(). resolveEffectiveColors() applies one-level color inheritance in-place on a collection of Tag entities.")
Rel(frontend, nlCtrl, "POST /api/search/nl with JSON query", "HTTP / JSON")
Rel(nlCtrl, rateLimiter, "checkAndConsume(userEmail)")
Rel(nlCtrl, parserSvc, "parse(query)")
Rel(parserSvc, ollamaClient, "parse(rawQuery) — extracts intent", "HTTP / JSON")
Rel(ollamaClient, ollama, "POST /api/generate (grammar-constrained JSON schema)", "HTTP / REST")
Rel(ollamaClient, ollama, "GET /api/tags (health check)", "HTTP / REST")
Rel(parserSvc, tagSvc, "findByNameContaining(keyword) — keyword→tag resolution")
Rel(parserSvc, tagSvc, "resolveEffectiveColors(tags)")
Rel(parserSvc, personSvc, "findByDisplayNameContaining(name) for each extracted name")
Rel(parserSvc, documentSvc, "searchDocuments() or searchDocumentsByPersonId()")
Rel(documentSvc, db, "JPA queries", "JDBC")
Rel(personSvc, db, "JPA queries", "JDBC")
Rel(tagSvc, db, "JPA queries", "JDBC")
@enduml

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 (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")

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
@@ -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

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,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

View File

@@ -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>&lt;BackButton /&gt; aus $lib/components/BackButton.svelte</td><td>history.back(); nicht &lt;a href&gt;</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>

View File

@@ -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&lt;JourneyItemView&gt;</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 &amp; 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&lt;JourneyItemView&gt;</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>

View File

@@ -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>

View File

@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zeitstrahl — Ereignis-Editor &amp; Brief-Gruppierung · Quick-Action im Dokument · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
.mh p{font-size:13px;color:#555;max-width:790px;line-height:1.75;margin-top:8px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tg.mint{background:#a1dcd8;color:#012851}
.tg.slate{background:#607080;color:#e8edf2}
.sh{margin:60px 0 22px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
.sh h2{font-size:17px;font-weight:900;color:#012851}
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:790px;line-height:1.65}
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
.callout code{font-family:'Courier New',monospace;font-size:11px;background:#fff;padding:1px 4px;border-radius:2px}
.callout strong{font-weight:800;color:#012851}
/* desktop chrome */
.dchrome{background:#F0EFE9;border:1.5px solid #C4C0BA;border-radius:9px;overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,.12)}
.dbar{height:22px;background:#E2DFD8;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;gap:4px;padding:0 9px}
.ddot{width:7px;height:7px;border-radius:50%;background:#C4BFB8}
.durl{flex:1;height:10px;background:#D2CEC8;border-radius:5px;margin:0 8px;max-width:360px}
.dnav{height:32px;background:#012851;display:flex;align-items:center;gap:13px;padding:0 16px}
.dnav .nlogo{font-family:'Tinos',serif;font-size:10px;color:#fff;font-weight:700}
.dnav .nlink{font-size:7.5px;color:rgba(255,255,255,.5);font-weight:700;text-transform:uppercase;letter-spacing:.4px}
.dnav .nlink.on{color:#fff;border-bottom:2px solid #a1dcd8;padding-bottom:9px}
.dnav .av{margin-left:auto;width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.55)}
.dcanvas{background:#f0efe9;padding:20px 26px 26px}
/* form atoms (mirror real Tailwind look) */
.lbl{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:6px}
.inp{border:1px solid #e4e2d7;border-radius:4px;background:#fff;padding:8px 11px;font-size:12px;color:#012851}
.inp.title{font-family:'Tinos',serif;font-size:22px;font-weight:700;padding:11px 13px}
.card{border:1px solid #e4e2d7;border-radius:6px;background:#fff;padding:15px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.card h3{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:3px}
.card .hint{font-size:9.5px;color:#9a9a96;margin-bottom:9px}
.seg{display:inline-flex;border:1px solid #012851;border-radius:5px;overflow:hidden}
.seg span{font-size:10px;font-weight:700;padding:5px 13px;color:#012851}
.seg span.on{background:#012851;color:#fff}
.seg span+span{border-left:1px solid #012851}
.chips{border:1px solid #e4e2d7;border-radius:5px;background:#fff;padding:7px;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.chip-sel{display:inline-flex;align-items:center;gap:4px;background:#f5f4ef;border-radius:4px;padding:3px 8px;font-size:10px;color:#012851}
.chip-sel .x{color:#012851;opacity:.45;font-size:11px}
.chip-in{flex:1;min-width:90px;font-size:10.5px;color:#9a9a96;padding:3px 4px}
.btn{display:inline-flex;align-items:center;gap:6px;height:38px;padding:0 16px;border-radius:5px;font-size:12px;font-weight:600}
.btn.primary{background:#012851;color:#fff}
.btn.ghost{background:#fff;border:1px solid #e4e2d7;color:#012851}
.btn.danger{background:#fff;border:1px solid #e7c9c4;color:#c0392b}
.tagchip{display:inline-flex;align-items:center;gap:3px;font-size:8px;border-radius:9px;padding:2px 7px;color:#a0522d;background:#f6ece6}
.tagchip i{width:6px;height:6px;border-radius:2px;background:#a0522d;display:inline-block}
/* annotation callouts on mockups */
.anno{display:flex;gap:9px;align-items:flex-start;font-size:11.5px;color:#4a4a46;line-height:1.55;margin-bottom:7px}
.anno .n{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;margin-top:1px}
.anno b{color:#012851}
.anno code{font-size:10px;background:#F0EFE9;padding:1px 4px;border-radius:2px}
.annogrid{display:grid;grid-template-columns:1fr 1fr;gap:6px 26px;margin-top:14px}
/* dropdown */
.dd{border:1px solid #e4e2d7;border-radius:6px;background:#fff;box-shadow:0 6px 18px rgba(0,0,0,.12);overflow:hidden;margin-top:3px}
.dd .opt{padding:7px 11px;font-size:11px;color:#012851;border-bottom:1px solid #f3f1ea;cursor:pointer}
.dd .opt:hover,.dd .opt.hl{background:#f5f4ef}
.dd .opt:last-child{border-bottom:none}
.dd .opt .d{font-size:8.5px;color:#9a9a96}
/* states grid */
.states{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}
.state{border:1px solid #E0DDD6;border-radius:8px;background:#fff;overflow:hidden}
.state .sh2{background:#F4F2EC;border-bottom:1px solid #E0DDD6;padding:7px 12px;font-size:8.5px;font-weight:800;letter-spacing:.8px;text-transform:uppercase;color:#012851;display:flex;justify-content:space-between}
.state .sb{padding:13px}
.cap{font-size:11px;color:#888;font-style:italic;line-height:1.55;margin-top:8px}
/* impl-ref */
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
.impl-ref table{width:100%;border-collapse:collapse}
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:185px}
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
hr{border:none;border-top:2px dashed #C8C4BE;margin:52px 0}
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
</style>
</head>
<body>
<div class="page">
<!-- ══ MASTHEAD ══ -->
<div class="mh">
<h1>Ereignis-Editor &amp; Brief-Gruppierung · Quick-Action im Dokument</h1>
<p>Wie kuratierte Zeitstrahl-Ereignisse entstehen und wie Briefe gruppiert werden — von zwei Seiten in ein Datenmodell (<code style="font-family:monospace;font-size:12px">TimelineEvent.documents</code>): der <strong>Ereignis-Editor</strong> unter <code style="font-family:monospace;font-size:12px">/zeitstrahl/events/[id]/edit</code> (Kurator baut, verlinkt viele Briefe) und die <strong>Quick-Action im Dokument-Detail</strong> (beim Lesen schnell zuordnen). Beide bauen auf bereits ausgelieferten Komponenten auf.</p>
<div class="tag-row">
<span class="tg">Milestone #14 · Zeitstrahl</span>
<span class="tg mint">Reuse: GeschichteEditor · DocumentMultiSelect · PersonMultiSelect</span>
<span class="tg slate">WRITE_ALL</span>
</div>
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · gegründet auf Code: GeschichteEditor.svelte · DocumentMetadataDrawer.svelte · DocumentMultiSelect.svelte</div>
</div>
<!-- ══ 1 · TWO ENTRY POINTS ══ -->
<div class="sh">
<h2>1 · Zwei Einstiegspunkte, ein Datenmodell</h2>
<p>Manuelle Gruppierung = ein <code>TimelineEvent</code> mit verknüpften Dokumenten. Kuratoren arbeiten in beide Richtungen — wir bauen beide, statt eine zu erzwingen.</p>
</div>
<div class="states" style="grid-template-columns:1fr 1fr">
<div class="callout navy" style="margin:0">
<strong>A · Ereignis-zuerst</strong> — der Kurator baut den Zeitstrahl. <code>/zeitstrahl/events/new · [id]/edit</code> mit Dokument-Mehrfach-Picker = <b>Bulk-Linking</b> vieler Briefe auf einmal. Spiegelt 1:1 den <code>GeschichteEditor</code> (gleiche zwei-Spalten-Form, Sidebar-Picker, Sticky-Save-Bar).
</div>
<div class="callout mint" style="margin:0">
<strong>B · Dokument-zuerst</strong> — beim Lesen eines Briefs. Quick-Action im Dokument-Detail: bestehendes Ereignis wählen <i>oder</i> neu anlegen, verlinkt diesen einen Brief. Spiegelt die bestehende <b>Geschichten-Spalte</b> im Details-Drawer (<code>DocumentMetadataDrawer.svelte</code>).
</div>
</div>
<!-- ══ 2 · EVENT EDITOR ══ -->
<div class="sh">
<h2>2 · Ereignis-Editor — <code style="font-size:14px">/zeitstrahl/events/[id]/edit</code></h2>
<p>Form-Actions-Muster, gegated mit <code>WRITE_ALL</code>. Layout &amp; Verhalten 1:1 vom <code>GeschichteEditor</code> übernommen: Hauptspalte + Sidebar (<code>lg:grid-cols-[2fr_1fr]</code>), Sticky-Save-Bar, <code>beforeNavigate</code>-Warnung bei ungespeicherten Änderungen.</p>
</div>
<div class="dchrome" style="margin-bottom:16px">
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
<div class="dnav"><span class="nlogo">Familienarchiv</span><span class="nlink">Dokumente</span><span class="nlink">Personen</span><span class="nlink on">Zeitstrahl</span><span class="nlink">Stammbaum</span><span class="av">KR</span></div>
<div class="dcanvas">
<div style="font-size:8px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#8a8a86;margin-bottom:10px"> Zurück zum Zeitstrahl</div>
<div style="font-family:'Tinos',serif;font-size:18px;font-weight:700;color:#012851;margin-bottom:16px">Ereignis bearbeiten</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:22px">
<!-- MAIN COLUMN -->
<div style="display:flex;flex-direction:column;gap:16px">
<!-- ① title -->
<div>
<div class="inp title" style="width:100%">Briefe von der Front</div>
</div>
<!-- ② type + ③ date/precision -->
<div style="display:flex;gap:22px;flex-wrap:wrap">
<div>
<div class="lbl">② Typ</div>
<div class="seg"><span class="on">Persönlich</span><span>Historisch</span></div>
</div>
<div>
<div class="lbl">③ Datum · Präzision</div>
<div style="display:flex;gap:6px;align-items:center">
<div class="inp" style="width:120px">1915</div>
<div class="inp" style="display:flex;align-items:center;gap:18px;color:#6b7280">Jahr <span style="font-size:8px"></span></div>
</div>
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Bei „Zeitspanne" erscheint ein zweites End-Datum-Feld. Bei „ca." / „Saison" passt sich nur das Label an.</div>
</div>
</div>
<!-- ④ description -->
<div>
<div class="lbl">④ Beschreibung <span style="color:#bbb;font-weight:600">· optional</span></div>
<div class="inp" style="width:100%;min-height:96px;color:#4a4a46;font-family:'Tinos',serif;line-height:1.6">Karls Feldpost von der Westfront, 1915 — wöchentliche Briefe an Elfriede und den neugeborenen Hans. Eine zusammenhängende Korrespondenz, die hier als Cluster gebündelt wird …</div>
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Schlichtes Textfeld (kein Rich-Text wie Geschichten) — Ereignisse sind kurze Notizen, keine Langform.</div>
</div>
</div>
<!-- SIDEBAR -->
<aside style="display:flex;flex-direction:column;gap:16px">
<!-- ⑤ linked letters = grouping -->
<div class="card" style="border-color:#a1dcd8;box-shadow:0 2px 10px rgba(161,220,216,.3)">
<h3 style="color:#012851">⑤ Verknüpfte Briefe · 24</h3>
<div class="hint">Diese Briefe bilden den Cluster. <code style="font-size:9px">DocumentMultiSelect</code></div>
<div class="chips">
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
<span class="chip-sel">✉ Brief an Elfriede · Sep 1915 <span class="x">×</span></span>
<span class="chip-in">Brief suchen …</span>
</div>
</div>
<!-- ⑥ persons -->
<div class="card">
<h3>⑥ Beteiligte Personen</h3>
<div class="hint">Treibt die Lebensweg-Ansicht &amp; Filter. <code style="font-size:9px">PersonMultiSelect</code></div>
<div class="chips">
<span class="chip-sel">Karl Raddatz <span class="x">×</span></span>
<span class="chip-sel">Elfriede Raddatz <span class="x">×</span></span>
<span class="chip-sel">Hans Raddatz <span class="x">×</span></span>
<span class="chip-in">Person suchen …</span>
</div>
</div>
</aside>
</div>
<!-- ⑦ sticky save bar -->
<div style="margin:18px -26px -26px;border-top:1px solid #e4e2d7;background:#fff;box-shadow:0 -2px 8px rgba(0,0,0,.05);padding:13px 26px;display:flex;align-items:center;justify-content:space-between">
<span style="font-size:10px;color:#9a9a96">Änderungen werden erst beim Speichern übernommen.</span>
<div style="display:flex;gap:8px">
<span class="btn danger">Löschen</span>
<span class="btn ghost">Abbrechen</span>
<span class="btn primary">Speichern</span>
</div>
</div>
</div>
</div>
<div class="annogrid">
<div class="anno"><span class="n"></span><span><b>Titel</b> — großes Serifen-Feld, wie der Geschichten-Titel. Pflichtfeld (Validierung bei Blur).</span></div>
<div class="anno"><span class="n"></span><span><b>Typ</b><code>PERSONAL</code> / <code>HISTORICAL</code> Segmented-Control. Steuert Rendering (Mint-Pille vs. Welt-Band).</span></div>
<div class="anno"><span class="n"></span><span><b>Datum + Präzision</b> — geteilte <code>DatePrecisionInput</code> (gleiche Logik wie Dokument-Datum, <code>metaDatePrecision</code>). „Zeitspanne" blendet End-Datum ein.</span></div>
<div class="anno"><span class="n"></span><span><b>Beschreibung</b> — optionales Textfeld (<code>TEXT</code>), bewusst schlicht.</span></div>
<div class="anno"><span class="n"></span><span><b>Verknüpfte Briefe</b><b>hier wird gruppiert.</b> Wiederverwendung von <code>DocumentMultiSelect</code> (Typeahead, Chips, Hidden-Inputs).</span></div>
<div class="anno"><span class="n"></span><span><b>Beteiligte Personen</b><code>PersonMultiSelect</code>. Bestimmt, in welchem „Lebensweg" das Ereignis auftaucht.</span></div>
<div class="anno"><span class="n"></span><span><b>Sticky-Save-Bar</b> — Speichern primär, Abbrechen sekundär, Löschen nur im Edit-Modus (mit Bestätigung).</span></div>
<div class="anno"><span class="n"></span><span><b>/new</b> — leeres Formular. Mit <code>?documentId=…</code> ist Feld ⑤ vorbefüllt (aus der Quick-Action, §4-D).</span></div>
</div>
<!-- ══ 3 · GROUPING / DOCUMENT PICKER ══ -->
<div class="sh">
<h2>3 · Brief-Gruppierung im Editor — der Dokument-Picker</h2>
<p>Feld ⑤ ist der unveränderte <code>DocumentMultiSelect</code>: Tippen sucht über <code>/api/documents/search?q=…</code> (debounced 300&nbsp;ms), Treffer mit ehrlichem Datums-Label, bereits gewählte werden gefiltert. Jeder Klick fügt einen Brief zum Cluster.</p>
</div>
<div class="states">
<div class="state">
<div class="sh2"><span>Suche aktiv — Dropdown</span><span style="color:#9a9a96">DocumentMultiSelect</span></div>
<div class="sb">
<div class="chips" style="margin-bottom:0">
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
<span class="chip-in" style="color:#012851">Verdun▏</span>
</div>
<div class="dd">
<div class="opt hl">Feldpost aus Verdun <span class="d">· Brief · Juli 1915</span></div>
<div class="opt">Brief aus dem Verdun-Lazarett <span class="d">· Brief · August 1916</span></div>
<div class="opt">Rückkehr aus Verdun <span class="d">· Brief · ca. 1917</span></div>
</div>
<div class="cap">Label = <code style="font-size:10px">title · formatDocumentDate(precision)</code>. Bereits verknüpfte Briefe erscheinen nicht in den Treffern (Dedup).</div>
</div>
</div>
<div class="state">
<div class="sh2"><span>Inline „+ Ereignis" am Jahres-Band</span><span style="color:#9a9a96">Zeitstrahl</span></div>
<div class="sb">
<div style="position:relative;padding-left:20px">
<div style="position:absolute;left:6px;top:2px;bottom:2px;width:2px;background:linear-gradient(#a1dcd8,#012851)"></div>
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;margin-bottom:8px">1915</div>
<div style="background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:7px 9px;margin-bottom:8px"><div style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</div><div style="font-size:8px;color:#9a9a96">Monats-Dichte ▾</div></div>
<button style="display:inline-flex;align-items:center;gap:5px;border:1px dashed #a1dcd8;background:#fff;border-radius:6px;padding:5px 11px;font-size:10px;font-weight:600;color:#012851"> Ereignis aus diesem Jahr anlegen</button>
</div>
<div class="cap">Kuratoren können auch direkt im Zeitstrahl ein Ereignis anlegen — öffnet denselben Editor, Jahr &amp; Briefe des Bandes vorbefüllt.</div>
</div>
</div>
</div>
<hr>
<!-- ══ 4 · QUICK ACTION IN DOCUMENT DETAIL ══ -->
<div class="sh">
<h2>4 · Quick-Action im Dokument-Detail — wo sie lebt</h2>
<p>Die Dokument-Detailseite ist ein <b>vollflächiger Viewer ohne Sidebar</b> (<code>fixed inset</code>). Aktions-Flächen gibt es nur zwei: die <code>DocumentTopBar</code> und den aufklappbaren <b>Details-Drawer</b>. Die Quick-Action lebt an beiden — primär als <b>„Zeitstrahl"-Spalte im Drawer</b> (spiegelt die Geschichten-Spalte), plus ein <b>Top-Bar-Button</b> für den Ein-Klick-Weg.</p>
</div>
<div class="callout navy"><strong>Warum der Details-Drawer der richtige Ort ist:</strong> Er zeigt heute schon, <i>wozu ein Brief gehört</i> — Personen, Schlagwörter und <b>Geschichten</b> (mit „Zuordnen"-Aktion, gegated über <code>canBlogWrite</code>, <code>DocumentMetadataDrawer.svelte:210</code>). Zeitstrahl-Ereignisse sind strukturell identisch („dieser Brief gehört zu diesen Ereignissen") und bekommen daher eine gleichwertige vierte/fünfte Spalte. Konsistent &amp; auffindbar dort, wo Nutzer ohnehin „Zugehörigkeit" suchen.</div>
<div class="dchrome" style="margin-bottom:16px">
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
<!-- document topbar -->
<div style="background:#fff;border-bottom:1px solid #e4e2d7;box-shadow:0 1px 3px rgba(0,0,0,.05);display:flex;align-items:center;height:54px">
<div style="width:3px;height:100%;background:#012851"></div>
<div style="width:34px;display:flex;justify-content:center;color:#6b7280;font-size:14px"></div>
<div style="width:1px;height:22px;background:#e4e2d7;margin:0 6px"></div>
<div style="flex:0 1 auto;min-width:0;padding-right:10px">
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Brief über die Lage an der Westfront</div>
<div style="font-size:8.5px;color:#6b7280">März 1915</div>
</div>
<div style="display:flex;gap:3px;margin-left:6px"><span style="width:20px;height:20px;border-radius:50%;background:#012851;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-size:9px;color:#9a9a96;align-self:center"></span><span style="width:20px;height:20px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">ER</span></div>
<div style="margin-left:auto;display:flex;align-items:center;gap:7px;padding-right:14px">
<!-- Details toggle (active) -->
<span style="display:inline-flex;align-items:center;gap:5px;background:#012851;color:#fff;border-radius:5px;font-size:10px;font-weight:600;padding:6px 11px">Details ▴</span>
<div style="width:1px;height:22px;background:#e4e2d7"></div>
<!-- action buttons -->
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;font-weight:600;color:#012851;padding:6px 11px">✎ Transkribieren</span>
<!-- NEW: Zeitstrahl quick button -->
<span style="display:inline-flex;align-items:center;gap:4px;background:#a1dcd8;color:#012851;border-radius:5px;font-size:10px;font-weight:700;padding:6px 11px">⊕ Zeitstrahl</span>
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;color:#012851;padding:6px 9px"></span>
</div>
</div>
<!-- metadata drawer (opened) -->
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:20px 24px">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:24px">
<!-- Details -->
<div>
<div class="lbl">Details</div>
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Datum</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">März 1915</div>
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Ort</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">Westfront</div>
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Status</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851">Transkribiert</div>
</div>
<!-- Personen -->
<div>
<div class="lbl">Personen</div>
<div style="display:flex;align-items:center;gap:7px;margin-bottom:6px"><span style="width:26px;height:26px;border-radius:50%;background:#012851;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Karl Raddatz</span></div>
<div style="display:flex;align-items:center;gap:7px"><span style="width:26px;height:26px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">ER</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Elfriede Raddatz</span></div>
</div>
<!-- Schlagwörter -->
<div>
<div class="lbl">Schlagwörter</div>
<div style="display:flex;flex-wrap:wrap;gap:5px"><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Krieg</span><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Briefe von der Front</span></div>
</div>
<!-- NEW: Zeitstrahl column -->
<div style="border-left:2px solid #eef6f5;padding-left:18px;margin-left:-6px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px">
<span class="lbl" style="margin:0;color:#012851">Zeitstrahl</span>
<span style="font-size:9px;font-weight:600;color:#6b7280">+ Zuordnen</span>
</div>
<!-- linked event -->
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px;margin-bottom:8px">
<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="color:#9a9a96;font-size:11px">×</span></div>
<div style="font-size:8px;color:#9a9a96;margin-top:1px">1915 · 24 Briefe · persönlich</div>
<span class="tagchip" style="margin-top:5px"><i></i>Krieg</span>
</div>
<!-- quick add row -->
<div style="display:flex;gap:6px">
<span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:9px;color:#9a9a96;padding:5px 8px">Ereignis suchen …</span>
<span style="background:#012851;color:#fff;border-radius:4px;font-size:9px;font-weight:600;padding:5px 8px;white-space:nowrap"> Neu</span>
</div>
</div>
</div>
</div>
<div style="height:120px;background:repeating-linear-gradient(45deg,#ececec,#ececec 8px,#e4e4e4 8px,#e4e4e4 16px);display:flex;align-items:center;justify-content:center;color:#aaa;font-size:10px">↓ PDF-Viewer (Brief-Scan) …</div>
</div>
<div class="annogrid">
<div class="anno"><span class="n">A</span><span><b>Top-Bar-Button „⊕ Zeitstrahl"</b> — Mint-Akzent im Aktions-Cluster (<code>DocumentTopBarActions</code>). Öffnet ein kleines Popover zum Ein-Klick-Zuordnen, ohne den Drawer zu öffnen. Im Mobile-Menü als Eintrag.</span></div>
<div class="anno"><span class="n">B</span><span><b>„Zeitstrahl"-Spalte im Details-Drawer</b> — neue Spalte neben Geschichten. Zeigt verknüpfte Ereignisse (Titel · Datum · Tag-Chip), Unlink über <code>×</code>, plus Quick-Add-Zeile. Nur sichtbar/aktiv bei <code>canWrite</code>.</span></div>
<div class="anno"><span class="n">C</span><span><b>Quick-Add-Zeile</b> — Typeahead „Ereignis suchen …" (sofortiges Verlinken, keine Navigation) + <b>„+ Neu"</b>.</span></div>
<div class="anno"><span class="n">D</span><span><b>„+ Neu"</b><code>/zeitstrahl/events/new?documentId={id}</code> — öffnet den Editor (§2) mit diesem Brief in Feld ⑤ vorbefüllt. Spiegelt <code>/geschichten/new?documentId=</code>.</span></div>
</div>
<!-- ══ 5 · QUICK-ADD STATES ══ -->
<div class="sh"><h2>5 · Quick-Action — Zustände</h2><p>Der Typeahead in der Zeitstrahl-Spalte (oder im Top-Bar-Popover). Gleiches Muster wie <code>DocumentMultiSelect</code>, nur sucht es Ereignisse statt Dokumente.</p></div>
<div class="states" style="grid-template-columns:repeat(2,1fr)">
<div class="state">
<div class="sh2"><span>A · Nicht zugeordnet</span></div>
<div class="sb">
<div style="font-size:10px;color:#9a9a96;font-style:italic;margin-bottom:9px">Noch keinem Ereignis zugeordnet.</div>
<div style="display:flex;gap:6px"><span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:10px;color:#9a9a96;padding:6px 9px">Ereignis suchen …</span><span class="btn primary" style="height:30px;font-size:10px;padding:0 11px"> Neu</span></div>
</div>
</div>
<div class="state">
<div class="sh2"><span>B · Suche — Treffer</span></div>
<div class="sb">
<div style="border:1px solid #012851;border-radius:4px;font-size:10px;color:#012851;padding:6px 9px;margin-bottom:0">Front▏</div>
<div class="dd">
<div class="opt hl">Briefe von der Front <span class="d">· 1915 · 24 Briefe</span></div>
<div class="opt">Kriegsausbruch <span class="d">· 1914 · 6 Briefe</span></div>
<div class="opt" style="color:#012851;font-weight:600"> „Front" als neues Ereignis anlegen</div>
</div>
</div>
</div>
<div class="state">
<div class="sh2"><span>C · Zugeordnet</span></div>
<div class="sb">
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px"><div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#2e7d57">✓ verknüpft <span style="color:#9a9a96">×</span></span></div><span class="tagchip" style="margin-top:5px"><i></i>Krieg</span></div>
<div class="cap">Sofortiges Verlinken (POST). Toast „Zum Ereignis hinzugefügt", <code style="font-size:10px">aria-live</code>. Unlink über <code>×</code> (DELETE).</div>
</div>
</div>
<div class="state">
<div class="sh2"><span>D · Mehrfach zugeordnet</span></div>
<div class="sb">
<div style="display:flex;flex-direction:column;gap:5px">
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#9a9a96">×</span></div>
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Weihnachten 1915</span><span style="font-size:9px;color:#9a9a96">×</span></div>
</div>
<div class="cap">Ein Brief darf zu mehreren Ereignissen gehören (ManyToMany) — alle werden gelistet.</div>
</div>
</div>
</div>
<!-- ══ 6 · TOKENS ══ -->
<div class="sh"><h2>6 · Wiederverwendete Bausteine &amp; Tokens</h2></div>
<div class="callout mint"><strong>Maximal wiederverwenden:</strong> <code>DocumentMultiSelect</code> (Brief-Gruppierung, unverändert) · <code>PersonMultiSelect</code> (Beteiligte) · <code>GeschichteEditor</code>-Layout (zwei Spalten, Sticky-Save, <code>beforeNavigate</code>) · <code>DocumentMetadataDrawer</code>-Spaltenmuster (Quick-Action) · <code>useUnsavedWarning</code> · <code>formatDocumentDate</code> / <code>DatePrecision</code>. Brand-Tokens wie im Zeitstrahl-Spec: Navy <code>#012851</code>, Mint <code>#a1dcd8</code>, Linie <code>#e4e2d7</code>, ink-3 <code>#6b7280</code>, danger <code>#c0392b</code>; Serifen-Titel (Tinos), Sans-Chrome (Montserrat).</div>
<!-- ══ 7 · IMPL-REF ══ -->
<div class="sh"><h2>7 · Implementierungs-Referenz &amp; Barrierefreiheit</h2></div>
<div class="impl-ref">
<table>
<tr><th>Baustein</th><th>Datei / Endpoint</th><th>Verantwortung</th></tr>
<tr><td>Editor-Route (neu)</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td><code>+page.server.ts</code> (Form-Actions, <code>WRITE_ALL</code>) + <code>+page.svelte</code>; <code>?documentId=</code> vorbefüllt Feld ⑤</td></tr>
<tr><td>Editor-Komponente (neu)</td><td><code>TimelineEventEditor.svelte</code></td><td>Spiegelt <code>GeschichteEditor</code>: Titel, Typ, Datum+Präzision, Beschreibung; Sidebar-Picker; Sticky-Save; <code>beforeNavigate</code></td></tr>
<tr><td>Brief-Gruppierung (reuse)</td><td><code>DocumentMultiSelect.svelte</code></td><td>Unverändert — Typeahead <code>/api/documents/search</code>, Chips, Hidden-Inputs <code>documentIds</code></td></tr>
<tr><td>Personen (reuse)</td><td><code>PersonMultiSelect.svelte</code></td><td>Unverändert — Beteiligte Personen</td></tr>
<tr><td>Datum + Präzision</td><td><code>DatePrecisionInput</code> (geteilt)</td><td>Wie Dokument-Datum (<code>metaDatePrecision</code>); „Zeitspanne" → End-Datum; <code>formatDocumentDate</code> fürs Label</td></tr>
<tr><td>Quick-Action-Spalte (neu)</td><td><code>DocumentTimelineColumn.svelte</code></td><td>Im <code>DocumentMetadataDrawer</code> neben Geschichten; verknüpfte Ereignisse + Quick-Add; nur bei <code>canWrite</code></td></tr>
<tr><td>Quick-Add-Picker (neu)</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Ereignis-Typeahead; sofort verlinken oder <code>?documentId=</code> zum Editor; auch im Top-Bar-Popover</td></tr>
<tr><td>Top-Bar-Button (neu)</td><td><code>DocumentTopBarActions</code> · <code>DocumentMobileMenu</code></td><td>„⊕ Zeitstrahl"-Button (canWrite); öffnet Quick-Add-Popover</td></tr>
<tr><td>Backend — CRUD</td><td><code>POST · PUT · DELETE /api/timeline/events</code></td><td><code>TimelineEventController</code>, <code>WRITE_ALL</code>; <code>TimelineEventRequest</code> mit <code>documentIds</code> / <code>personIds</code></td></tr>
<tr><td>Backend — Link/Unlink</td><td><code>PUT /api/timeline/events/{id}</code></td><td>Verlinken/Lösen läuft über das Event-Update (<code>documents</code>-Set); kein neuer ErrorCode nötig</td></tr>
<tr><td>Barrierefreiheit</td><td></td><td>Picker-Dropdowns Tastatur-navigierbar (↑↓↵), <code>aria-live</code> für „verknüpft/gelöst"; 44px-Ziele; sichtbarer Fokus-Ring; Löschen/Unlink mit Bestätigung</td></tr>
</table>
</div>
<div class="note">Offene Designentscheidung: Soll der Top-Bar-Button (A) MVP sein oder reicht zunächst die Drawer-Spalte (B)? Empfehlung: <b>B als MVP</b> (spiegelt Geschichten exakt, geringster Aufwand), A als schneller Nachzug.</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Globaler Zeitstrahl — Finale Spezifikation (Konzept A) · Milestone #14 · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
/* Masthead */
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:8px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tg.mint{background:#a1dcd8;color:#012851}
.tg.slate{background:#607080;color:#e8edf2}
.sh{margin:60px 0 24px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
.sh h2{font-size:17px;font-weight:900;color:#012851}
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:780px;line-height:1.65}
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
.callout strong{font-weight:800;color:#012851}
/* legend */
.legend{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.lg{background:#fff;border:1px solid #E0DDD6;border-radius:7px;padding:12px 14px;display:flex;gap:11px}
.lg .ico{flex-shrink:0;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px}
.lg .ttl{font-size:10.5px;font-weight:800;color:#012851;margin-bottom:2px}
.lg .body{font-size:9.5px;color:#5a5a56;line-height:1.5}
.lg .body code{font-size:8.5px;background:#F0EFE9;padding:1px 3px;border-radius:2px;color:#444}
/* precision table */
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
.rules table{width:100%;border-collapse:collapse}
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
.rules td{font-size:11px;color:#444;padding:7px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.5}
.rules tr:last-child td{border-bottom:none}
.rules td:first-child{font-weight:700;color:#012851;width:110px}
.rules td code{font-size:10px;background:#F0EFE9;padding:1px 5px;border-radius:2px;color:#444}
.rules .ex{font-family:'Tinos',serif;font-size:13px;color:#012851}
/* ── Timeline atoms (Konzept A) ── */
.tl-canvas{background:#f0efe9;border:1.5px solid #E0DDD6;border-radius:10px;padding:24px 26px 30px}
.dh{font-family:'Tinos',serif;font-size:20px;font-weight:700;color:#012851}
.dh-sub{font-size:9.5px;color:#7a7a76;margin-bottom:20px}
/* desktop centered axis */
.axis{position:relative;max-width:780px;margin:0 auto;padding:4px 0}
.axis::before{content:"";position:absolute;left:50%;top:0;bottom:0;width:2.5px;background:linear-gradient(#a1dcd8,#012851,#607080);transform:translateX(-50%)}
.ybadge{text-align:center;position:relative;margin:4px 0 14px}
.ybadge span{background:#012851;color:#fff;font-family:'Tinos',serif;font-size:13px;font-weight:700;padding:2px 15px;border-radius:12px;position:relative;z-index:2}
.pill{text-align:center;position:relative;margin-bottom:15px}
.pill .inner{display:inline-flex;align-items:center;gap:9px;background:#fff;border:1.5px solid #012851;border-radius:22px;padding:5px 16px 5px 5px;position:relative;z-index:2;box-shadow:0 2px 7px rgba(1,40,81,.12)}
.pill.curated .inner{border-color:#a1dcd8;border-width:2px}
.pill .gly{width:28px;height:28px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.pill.curated .gly{background:#a1dcd8;color:#012851}
.pill .tx{text-align:left}
.pill .tx .t{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;display:block;line-height:1.15}
.pill .tx .s{font-size:8.5px;color:#8a8a86}
.wband{position:relative;margin:0 -26px 15px;padding:7px 26px;background:#EEEBE2;border-top:1px solid #ddd8cc;border-bottom:1px solid #ddd8cc;text-align:center}
.wband .t{font-family:'Tinos',serif;font-style:italic;font-size:12px;color:#5a6776}
.wband .s{font-size:8.5px;color:#8a8a86;margin-left:8px}
.lrow{display:flex;align-items:flex-start;margin-bottom:12px}
.lrow .half{flex:1}
.lrow .dot{width:13px;height:13px;border-radius:50%;background:#fff;border:2.5px solid #a1dcd8;margin-top:9px;flex-shrink:0;z-index:2;position:relative}
.lrow .a{padding-right:26px;text-align:right}
.lrow .b{padding-left:26px;text-align:left}
.lcard{display:inline-block;text-align:left;background:#fff;border:1px solid #e4e2d7;border-radius:5px;padding:8px 11px;box-shadow:0 1px 3px rgba(0,0,0,.05);max-width:300px}
.lcard.ev{border-left:3px solid #a1dcd8}
.lcard .t{font-size:11px;font-weight:700;color:#1A1A1A}
.lcard.ev .t{font-family:'Tinos',serif}
.lcard .m{font-size:8.5px;color:#9a9a96;margin-top:1px}
.chip{display:inline-flex;align-items:center;gap:3px;font-size:7.5px;border-radius:9px;padding:2px 7px;margin-top:5px}
.chip i{width:6px;height:6px;border-radius:2px;display:inline-block;flex-shrink:0}
.chip.krieg{color:#a0522d;background:#f6ece6}.chip.krieg i{background:#a0522d}
.chip.weih{color:#c17a00;background:#fbf3e3}.chip.weih i{background:#c17a00}
.chip.fam{color:#5a8a6a;background:#eaf1ec}.chip.fam i{background:#5a8a6a}
/* dense aggregate strip */
.strip{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;background:#fff;border:1px solid #e4e2d7;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.05);padding:9px 13px}
.strip .hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.strip .ct{font-size:10.5px;font-weight:700;color:#012851}
.strip .ex{font-size:8px;color:#8a8a86}
.spark{display:flex;align-items:flex-end;gap:1.5px;height:30px}
.spark div{flex:1;background:#a1dcd8;border-radius:1px;min-height:1px}
.strip .axl{display:flex;justify-content:space-between;margin-top:3px}
.strip .axl span{font-size:6px;color:#bbb}
/* compressed gap */
.gap{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;display:flex;align-items:center;gap:9px;padding:5px 14px;border:1px dashed #cfccc4;border-radius:18px;background:#f0efe9;color:#9a958c;font-size:9px;font-style:italic}
.gap .ln{flex:1;height:1px;background:#ddd8cc}
.gap b{color:#7a756c;font-style:normal;font-family:'Tinos',serif;font-size:10px}
/* undated bucket */
.undated{max-width:540px;margin:20px auto 0;background:#fff;border:1px dashed #c4c0ba;border-radius:6px;padding:12px 15px}
.undated .h{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#7a756c;margin-bottom:6px}
/* case tag floating */
.casetag{display:inline-block;background:#012851;color:#a1dcd8;font-size:7px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;padding:2px 7px;border-radius:3px;margin-bottom:6px}
/* narrow (phone / rail) column */
.col3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.modehd{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#012851;margin-bottom:4px;display:flex;align-items:center;gap:6px}
.modehd .seg{background:#012851;color:#fff;font-size:7px;padding:2px 7px;border-radius:4px}
.modesub{font-size:9.5px;color:#888;font-style:italic;margin-bottom:9px;line-height:1.45;min-height:42px}
.nf{background:#fff;border:1px solid #e4e2d7;border-radius:6px;padding:14px}
.nsp{position:relative;padding-left:21px}
.nsp::before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:linear-gradient(#a1dcd8,#012851,#607080)}
.nyr{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;margin:2px 0 7px;position:relative}
.nyr::before{content:"";position:absolute;left:-14px;top:4px;width:8px;height:8px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851}
.nnode{position:relative;margin-bottom:9px}
.nnode .g{position:absolute;left:-18px;top:0;width:14px;height:14px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851;color:#a1dcd8;font-size:8px;display:flex;align-items:center;justify-content:center}
.nnode .lt{font-family:'Tinos',serif;font-size:10.5px;font-weight:700;color:#012851;line-height:1.2}
.nnode .lm{font-size:7.5px;color:#8a8a86}
.nwb{position:relative;margin:0 0 9px -9px;padding:5px 8px;background:#EEEBE2;border-left:2px solid #607080;border-radius:0 3px 3px 0}
.nwb .t{font-family:'Tinos',serif;font-style:italic;font-size:9.5px;color:#5a6776}
.nwb .s{font-size:7px;color:#8a8a86}
.nletter{position:relative;margin-bottom:7px}
.nletter .d{position:absolute;left:-15px;top:3px;width:6px;height:6px;border-radius:50%;background:#fff;border:1.5px solid #a1dcd8}
.ncard{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:5px 8px}
.ncard .t{font-size:9px;font-weight:700;color:#1A1A1A}
.ncard .m{font-size:7px;color:#9a9a96}
.nstrip{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:6px 8px;margin-bottom:7px}
.nbucket{border:1px solid #e4e2d7;border-radius:4px;padding:5px 8px;margin-bottom:5px;display:flex;align-items:center;justify-content:space-between}
/* impl-ref */
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
.impl-ref table{width:100%;border-collapse:collapse}
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:165px}
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
.sw{display:inline-block;width:11px;height:11px;border-radius:2px;vertical-align:-1px;margin-right:5px;border:1px solid rgba(0,0,0,.12)}
hr{border:none;border-top:2px dashed #C8C4BE;margin:50px 0}
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
</style>
</head>
<body>
<div class="page">
<!-- ══ MASTHEAD ══ -->
<div class="mh">
<h1>Globaler Zeitstrahl — Finale Spezifikation</h1>
<p>Kanonische Spezifikation für <code style="font-family:monospace;font-size:12px">/zeitstrahl</code> auf Basis von <strong>Konzept A „Der Lebensfaden"</strong>: eine durchgehende vertikale Achse, die Personen-Lebensereignisse, kuratierte Ereignisse und Briefe zu einer Erzählung in der Zeit verwebt. Dieselbe Komponente betreibt den globalen Zeitstrahl und den per-Person „Lebensweg". Enthält die vollständige Fall-Abdeckung (leere Jahre, wenige Briefe, hunderte Briefe, undatiert) und die drei Gruppierungs-Modi.</p>
<div class="tag-row">
<span class="tg">Milestone #14 · Zeitstrahl</span>
<span class="tg mint">Konzept A — final</span>
<span class="tg slate">Phone-first · honest DatePrecision</span>
</div>
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · ersetzt die A/B/C-Exploration zeitstrahl-global-concepts.html</div>
</div>
<!-- ══ 1 · ANATOMIE ══ -->
<div class="sh">
<h2>1 · Anatomie von Konzept A</h2>
<p>Eine Achse, sieben Bausteine. Die Zeit ist die Achse — Lebensereignisse &amp; Jahre als zentrierte Pillen <i>unterbrechen</i> den Faden (Text wird nie von der Linie gekreuzt), Welt-Ereignisse legen sich als Bänder quer, Briefe verdichten sich adaptiv.</p>
</div>
<div class="legend" style="margin-bottom:16px">
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8"></div><div><div class="ttl">Lebensereignis-Pille</div><div class="body">Geburt <b>*</b> · Tod <b></b> · Heirat <b></b>. Abgeleitet aus <code>Person</code>-Daten. Zentriert, gefüllt — unterbricht die Achse. Glyphen aus <code>personLifeDates.ts</code>.</div></div></div>
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8;color:#012851"></div><div><div class="ttl">Kuratierte Ereignis-Pille</div><div class="body"><code>PERSONAL</code> — Umzug, Auswanderung. Mint-Rand. Editierbar im Kurator-Editor.</div></div></div>
<div class="lg"><div class="ico" style="background:#EEEBE2;color:#607080;border:1px solid #607080"></div><div><div class="ttl">Welt-Band</div><div class="body"><code>HISTORICAL</code> — Krieg, Inflation. Gedämpftes Band quer über die Achse als Kontext.</div></div></div>
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8"></div><div><div class="ttl">Einzel-Brief</div><div class="body">Kleiner Punkt + Karte, alternierend links/rechts. Wurzel-Tag-Farbchip. Link zu <code>/documents/[id]</code>.</div></div></div>
<div class="lg"><div class="ico" style="background:#a1dcd8;color:#012851;font-size:11px"></div><div><div class="ttl">Jahres-Strip</div><div class="body">Verdichtung dichter Jahre: Anzahl + 12-Monats-Sparkline. <code>MonthBucket</code> / <code>aggregateToYears</code>.</div></div></div>
<div class="lg"><div class="ico" style="background:#f0efe9;border:1px dashed #c4c0ba;color:#9a958c;font-size:11px"></div><div><div class="ttl">Lücke &amp; Ohne-Datum</div><div class="body">Ruhige/leere Jahre als dünne Span-Zeile gefaltet; <code>UNKNOWN</code>-Briefe im „Ohne Datum"-Eimer am Ende.</div></div></div>
</div>
<div class="callout navy"><strong>Gruppierungs-Umschalter</strong> (oben rechts im Zeitstrahl): <span style="display:inline-flex;border:1.5px solid #012851;border-radius:5px;overflow:hidden;vertical-align:middle;margin:0 4px"><span style="background:#012851;color:#fff;font-size:9px;font-weight:700;padding:3px 11px">Datum</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Ereignis</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Thema</span></span> steuert <b>nur, wie lose Briefe gebündelt werden</b>. Lebensereignisse, kuratierte Ereignisse und Welt-Bänder bleiben in allen Modi gleich auf der Achse. Standard = <b>Datum</b>.</div>
<!-- ══ 2 · GROUPING MODES ══ -->
<div class="sh">
<h2>2 · Die drei Gruppierungs-Modi</h2>
<p>Gleicher Ausschnitt (19141915), dreimal gerendert. Nur die <b>losen Briefe</b> ordnen sich um — die Achse bleibt stabil. Schmale Spaltenbreite = Phone-/Lebensweg-Form derselben Komponente.</p>
</div>
<div class="col3">
<!-- MODE: DATUM -->
<div>
<div class="modehd"><span class="seg">Datum</span> Chronologisch</div>
<div class="modesub">Standard. Briefe nach Datum; dichte Jahre verdichten zum Strip. Reine Zeit-Reihung.</div>
<div class="nf">
<div class="nsp">
<div class="nyr">1914</div>
<div class="nnode"><span class="g"></span><div class="lt">Heirat: Karl &amp; Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">19141918</div></div>
<div class="nletter"><span class="d"></span><div class="ncard"><div class="t">✉ Kriegsausbruch — Brief an die Familie</div><div class="m">Karl → Elfriede · 4. Aug 1914</div></div></div>
<div class="nyr">1915</div>
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
<div class="nstrip">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px"><span style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</span><span style="font-size:7px;color:#8a8a86">Monate ▾</span></div>
<div style="display:flex;align-items:flex-end;gap:1.5px;height:20px"><div style="flex:1;height:20%;background:#a1dcd8"></div><div style="flex:1;height:35%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:70%;background:#a1dcd8"></div><div style="flex:1;height:60%;background:#a1dcd8"></div><div style="flex:1;height:85%;background:#a1dcd8"></div><div style="flex:1;height:100%;background:#a1dcd8"></div><div style="flex:1;height:88%;background:#a1dcd8"></div><div style="flex:1;height:72%;background:#a1dcd8"></div><div style="flex:1;height:48%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:40%;background:#a1dcd8"></div></div>
</div>
</div>
</div>
</div>
<!-- MODE: EREIGNIS -->
<div>
<div class="modehd"><span class="seg">Ereignis</span> Kuratiert</div>
<div class="modesub">Briefe bündeln unter kuratierte Ereignisse (<code>TimelineEvent.documents</code>). Erzählende Cluster statt Listen.</div>
<div class="nf">
<div class="nsp">
<div class="nyr">1914</div>
<div class="nnode"><span class="g"></span><div class="lt">Heirat: Karl &amp; Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">19141918</div></div>
<div class="nyr">1915</div>
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Briefe von der Front · 24</div><div class="m">Karl ⇄ Elfriede &amp; Hans · 1915 ▾</div><span class="chip krieg" style="margin-top:4px"><i></i>Krieg</span></div></div>
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Weihnachten 1915 · 3</div><div class="m">kuratiertes Ereignis ▾</div><span class="chip weih" style="margin-top:4px"><i></i>Weihnachten</span></div></div>
</div>
</div>
</div>
<!-- MODE: THEMA -->
<div>
<div class="modehd"><span class="seg">Thema</span> Nach Wurzel-Tag</div>
<div class="modesub">Optional (Post-MVP). Lose Briefe je Jahr in Wurzel-Tag-Eimer; Mehrfach-Tags dedupliziert auf den primären.</div>
<div class="nf">
<div class="nsp">
<div class="nyr">1914</div>
<div class="nnode"><span class="g"></span><div class="lt">Heirat: Karl &amp; Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">19141918</div></div>
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg</span><span style="font-size:8px;color:#8a8a86">6 ▾</span></div>
<div class="nyr">1915</div>
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg Briefe von der Front</span><span style="font-size:8px;color:#8a8a86">24 ▾</span></div>
<div class="nbucket"><span class="chip weih" style="margin:0"><i></i>Weihnachten</span><span style="font-size:8px;color:#8a8a86">3 ▾</span></div>
<div class="nbucket"><span class="chip fam" style="margin:0"><i></i>Familie</span><span style="font-size:8px;color:#8a8a86">2 ▾</span></div>
</div>
</div>
<div class="note" style="margin-top:8px">Hinweis im UI: „Brief mit mehreren Tags erscheint unter seinem primären Tag."</div>
</div>
</div>
<!-- ══ 3 · ALL CASES PREVIEW ══ -->
<div class="sh">
<h2>3 · Vollständige Vorschau — alle Dichte-Fälle</h2>
<p>Ein durchgehender Zeitstrahl (Desktop, zentrale Achse) von 1899 bis „Ohne Datum". Jeder Dichte-Fall kommt genau einmal vor — von leeren Jahren bis zu hunderten Briefen.</p>
</div>
<div class="callout navy" style="margin-bottom:18px">
<strong>Abgedeckte Fälle:</strong>
① leere Jahre (gefaltete Lücke) ·
② wenige Briefe ≤ 3 (einzelne Karten) ·
③ hunderte Briefe (Jahres-Strip + Sparkline) ·
④ kuratiertes Ereignis &amp; Welt-Band ·
⑤ ungenaue Präzision (<code>Sommer</code>, <code>ca.</code>) ·
⑥ undatierte Briefe (Ohne-Datum-Eimer).
</div>
<div class="tl-canvas">
<div class="dh">Zeitstrahl</div>
<div class="dh-sub">Die Familie Raddatz · 18991950 · 412 Briefe · 38 Ereignisse &nbsp;·&nbsp; <span style="color:#012851">Gruppierung: Datum</span></div>
<div style="text-align:center;margin-bottom:8px"><span class="casetag">① Leere Jahre → gefaltet</span></div>
<div class="axis">
<!-- ① empty years gap -->
<div class="gap"><span class="ln"></span><b>1899 1908</b> · keine Einträge<span class="ln"></span></div>
<!-- ② 3 letters -->
<div class="ybadge"><span>1909</span></div>
<div style="text-align:center;margin-bottom:4px"><span class="casetag">② Wenige Briefe → einzeln</span></div>
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief aus Stettin</div><div class="m">Elfriede → Karl · Mai 1909</div><span class="chip fam"><i></i>Familie</span></div></div><div class="dot"></div><div class="half b"></div></div>
<div class="lrow"><div class="half a"></div><div class="dot"></div><div class="half b"><div class="lcard"><div class="t">✉ Geburtstagsgruß</div><div class="m">Karl → Hans · Sep 1909</div></div></div></div>
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief zum Jahresende</div><div class="m">Karl → Elfriede · Dez 1909</div><span class="chip weih"><i></i>Weihnachten</span></div></div><div class="dot"></div><div class="half b"></div></div>
<!-- ④ life event + world band -->
<div class="ybadge"><span>1914</span></div>
<div class="pill"><span class="inner"><span class="gly"></span><span class="tx"><span class="t">Heirat: Karl &amp; Elfriede Raddatz</span><span class="s">1914 · abgeleitet aus Beziehung</span></span></span></div>
<div style="text-align:center;margin-bottom:6px"><span class="casetag">④ Welt-Band (RANGE 19141918)</span></div>
<div class="wband"><span class="t">◍ Erster Weltkrieg</span><span class="s">19141918 · historisch · 187 Briefe in dieser Zeit</span></div>
<!-- ③ hundreds of letters strip -->
<div class="ybadge"><span>1915</span></div>
<div style="text-align:center;margin-bottom:6px"><span class="casetag">③ Hunderte Briefe → Jahres-Strip</span> &nbsp; <span class="casetag" style="background:#607080">⑤ „Sommer 1915"</span></div>
<div class="pill"><span class="inner"><span class="gly">*</span><span class="tx"><span class="t">Geburt: Hans Raddatz</span><span class="s">Sommer 1915 · abgeleitet · SEASON</span></span></span></div>
<div class="strip">
<div class="hd"><span class="ct">✉ 187 Briefe</span><span class="ex">Monats-Dichte · antippen → Monate → Briefe ▾</span></div>
<div class="spark"><div style="height:22%"></div><div style="height:30%"></div><div style="height:48%"></div><div style="height:66%"></div><div style="height:58%"></div><div style="height:80%"></div><div style="height:100%"></div><div style="height:92%"></div><div style="height:74%"></div><div style="height:50%"></div><div style="height:44%"></div><div style="height:34%"></div></div>
<div class="axl"><span>Jan 1915</span><span>Dez 1915</span></div>
</div>
<!-- empty-ish span with some letters collapsed -->
<div class="gap"><span class="ln"></span><b>1916 1922</b> · Nachkriegsjahre · 96 Briefe<span class="ln"></span></div>
<!-- ⑤ approx precision world band -->
<div style="text-align:center;margin-bottom:6px"><span class="casetag" style="background:#607080">⑤ „ca. 1923" → APPROX</span></div>
<div class="wband"><span class="t">◍ Hyperinflation</span><span class="s">ca. 1923 · historisch</span></div>
<!-- curated personal event -->
<div class="ybadge"><span>1924</span></div>
<div class="pill curated"><span class="inner"><span class="gly"></span><span class="tx"><span class="t">Auswanderung nach Argentinien</span><span class="s">Frühjahr 1924 · persönlich · kuratiert</span></span></span></div>
<!-- tail empty -->
<div class="gap"><span class="ln"></span><b>1925 1950</b> · keine Einträge<span class="ln"></span></div>
</div>
<!-- ⑥ undated bucket -->
<div style="text-align:center;margin:6px 0"><span class="casetag" style="background:#7a756c">⑥ Undatiert → eigener Eimer am Ende</span></div>
<div class="undated">
<div class="h">Ohne Datum · 11 Briefe</div>
<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-top:1px solid #f0eee8"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Brief ohne Jahresangabe</span><span style="font-size:8px;color:#aaa">Präzision UNKNOWN</span></div>
<div style="display:flex;align-items:center;gap:8px;padding:4px 0"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Fragment, Absender unklar</span><span style="font-size:8px;color:#aaa">+ 9 weitere ▾</span></div>
</div>
</div>
<div class="note">Kein erfundenes Datum: undatierte Briefe wandern nie spekulativ in ein Jahr, sondern bleiben sichtbar im Eimer. <code>RANGE</code>-Einträge (Krieg) erscheinen einmal im Start-Jahr mit Spannen-Marker, nicht in jedem überspannten Jahr.</div>
<!-- ══ 4 · PRECISION ══ -->
<div class="sh"><h2>4 · Datums-Präzision (geteilt von Ereignissen &amp; Briefen)</h2><p>Eine Render-Logik für alle datierten Einträge — <code>dateLabel.ts</code>, gespeist von <code>DatePrecision</code>.</p></div>
<div class="rules">
<table>
<tr><th>DatePrecision</th><th>Darstellung</th><th>Beispiel</th><th>Wirkung auf der Achse</th></tr>
<tr><td>DAY</td><td>vollständiges Datum</td><td class="ex">28. Juli 1914</td><td>exakte Sortierung im Jahres-Band</td></tr>
<tr><td>MONTH</td><td>Monat + Jahr</td><td class="ex">Juli 1914</td><td>Monats-Sortierung</td></tr>
<tr><td>SEASON</td><td>Jahreszeit + Jahr</td><td class="ex">Sommer 1914</td><td>grobe Reihung</td></tr>
<tr><td>YEAR</td><td>nur Jahr</td><td class="ex">1914</td><td>ans Band-Ende</td></tr>
<tr><td>APPROX</td><td>„ca." + Jahr</td><td class="ex">ca. 1914</td><td>mit „ca."-Marker</td></tr>
<tr><td>RANGE</td><td>StartEnde</td><td class="ex">19141918</td><td>Start-Jahr, Spannen-Marker, nicht dupliziert</td></tr>
<tr><td>UNKNOWN</td><td>undatiert</td><td class="ex">Ohne Datum</td><td>eigener Eimer am Ende</td></tr>
</table>
</div>
<!-- ══ 5 · RESPONSIVE ══ -->
<div class="sh"><h2>5 · Responsiv — eine Komponente, drei Breiten</h2><p>Identisches Markup &amp; identische Daten. Nur die Achs-Position wechselt per Container-Query.</p></div>
<div class="legend" style="grid-template-columns:repeat(3,1fr)">
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px"></div><div><div class="ttl">≥ 1024px · Desktop</div><div class="body"><b>Zentrale Achse</b>, Briefe alternierend links/rechts, Welt-Bänder über volle Breite. Pillen unterbrechen die Linie.</div></div></div>
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px"></div><div><div class="ttl">&lt; 1024px · Phone</div><div class="body"><b>Linke Achse</b>, alles einseitig rechts. DOM-Reihenfolge bleibt streng chronologisch (<code>&lt;ol&gt;</code>) — Screenreader liest linear.</div></div></div>
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px"></div><div><div class="ttl">Lebensweg-Rail · 35%</div><div class="body">Gleiche linke Achse in der Personenseite (<code>&lt;TimelineView personId&gt;</code>), gefiltert auf eine Person. Rail-Tauglichkeit = Stärke von A.</div></div></div>
</div>
<!-- ══ 6 · TOKENS ══ -->
<div class="sh"><h2>6 · Design-Tokens (echte, ausgelieferte Werte)</h2><p>Aus <code>frontend/src/routes/layout.css</code>. Keine Hardcodes in der Komponente.</p></div>
<div class="impl-ref">
<table>
<tr><th>Rolle</th><th>Token</th><th>Wert</th><th>Einsatz</th></tr>
<tr><td>Achse / Knoten / Header</td><td><code>brand-navy</code></td><td><span class="sw" style="background:#012851"></span>#012851</td><td>Spine, Lebensereignis-Pillen, Jahres-Badges, Titel</td></tr>
<tr><td>Akzent / Brief-Punkt</td><td><code>brand-mint</code></td><td><span class="sw" style="background:#a1dcd8"></span>#a1dcd8</td><td>Brief-Punkte, kuratierte Pillen-Ränder, Sparkline, Dark-Mode-Auswahl</td></tr>
<tr><td>Historisch / Welt</td><td><code>tag-slate</code></td><td><span class="sw" style="background:#607080"></span>#607080</td><td>Welt-Bänder &amp; Glyphe ◍ — gedämpft</td></tr>
<tr><td>Tag-Chip-Farben</td><td><code>--c-tag-*</code> (Wurzel)</td><td><span class="sw" style="background:#5a8a6a"></span><span class="sw" style="background:#a0522d"></span><span class="sw" style="background:#c17a00"></span><span class="sw" style="background:#7a4f9a"></span></td><td>sage · sienna · amber · violet — Farbe vom Wurzel-Tag, Punkt + Label</td></tr>
<tr><td>Seite / Karte / Linie</td><td><code>canvas · surface · line</code></td><td><span class="sw" style="background:#f0efe9"></span><span class="sw" style="background:#fff"></span><span class="sw" style="background:#e4e2d7"></span></td><td>#f0efe9 · #ffffff · #e4e2d7</td></tr>
<tr><td>Text sekundär</td><td><code>text-ink-3</code></td><td><span class="sw" style="background:#6b7280"></span>#6b7280</td><td>Meta-Zeilen (4,8:1 auf weiß — AA ✓)</td></tr>
<tr><td>Schrift</td><td><code>font-serif · font-sans</code></td><td>Tinos · Montserrat</td><td>Namen/Titel serif · Labels/Chrome sans</td></tr>
<tr><td>Lebensdaten-Glyphen</td><td><code>personLifeDates.ts</code></td><td>* † ⚭</td><td>Geburt · Tod · Heirat — konsistent mit Personenkarten</td></tr>
</table>
</div>
<!-- ══ 7 · IMPL-REF ══ -->
<div class="sh"><h2>7 · Implementierungs-Referenz &amp; Barrierefreiheit</h2><p>Domain-Ordner <code>frontend/src/lib/timeline/</code>; Route <code>/zeitstrahl</code>; Backend <code>GET /api/timeline</code>.</p></div>
<div class="impl-ref">
<table>
<tr><th>Baustein</th><th>Komponente / Datei</th><th>Verantwortung</th></tr>
<tr><td>Orchestrator</td><td><code>TimelineView.svelte</code></td><td>Lädt <code>/api/timeline</code>; optionaler <code>personId</code> für globalen vs. Lebensweg-Modus; hält den Gruppierungs-Modus</td></tr>
<tr><td>Jahres-Band</td><td><code>YearBand.svelte</code></td><td>Jahres-Badge + Einträge; Lücken-Faltung ruhiger Spannen</td></tr>
<tr><td>Ereignis-Pille</td><td><code>EventCard.svelte</code></td><td>PERSONAL / HISTORICAL / abgeleitet; zentrierte Pille bzw. Welt-Band; präzisions-bewusstes Label</td></tr>
<tr><td>Brief-Karte</td><td><code>LetterCard.svelte</code></td><td>Einzel-Brief, alternierende Seite, Wurzel-Tag-Chip, Link <code>/documents/[id]</code></td></tr>
<tr><td>Jahres-Strip</td><td><code>YearLetterStrip.svelte</code></td><td>Adaptive Verdichtung ab Schwellwert; 12-Monats-Sparkline aus <code>MonthBucket</code> / <code>aggregateToYears</code> (<code>lib/document/timeline.ts</code>)</td></tr>
<tr><td>Datums-Helfer</td><td><code>dateLabel.ts</code></td><td><code>DatePrecision</code> → deutsches Label; geteilt von Ereignissen &amp; Briefen</td></tr>
<tr><td>Kurator-Editor</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td>Ereignis anlegen/bearbeiten; Personen- + Dokument-Mehrfach-Picker (Bulk-Linking); <code>WRITE_ALL</code></td></tr>
<tr><td>Quick-Add</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Auf <code>/documents/[id]</code>: Ereignis wählen/neu anlegen; verlinkt einen Brief</td></tr>
<tr><td>Daten-API</td><td><code>GET /api/timeline</code></td><td>Verschmilzt kuratierte + abgeleitete Ereignisse + Briefe in <code>TimelineDTO</code> (Jahres-Eimer + Ohne-Datum); Filter <code>personId · type · fromYear · toYear</code></td></tr>
<tr><td>Barrierefreiheit</td><td></td><td>Achse = <code>&lt;ol&gt;</code>, chronologische DOM-Reihenfolge; ◍ ✉ * nie nur Farbe — Glyphe + Label; 44px-Tap-Ziele; <code>prefers-reduced-motion</code>; axe in Light &amp; Dark</td></tr>
</table>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
# Family Timeline (Zeitstrahl) — Design Spec
**Date:** 2026-06-07
**Status:** Approved — pending implementation plan
## Problem
The archive can capture, transcribe, organize, and browse letters, but the transcribed material does not yet add up to a *story in time*. Readers (younger, phone-first) have no way to feel the family's history unfold; transcribers don't see their work become something larger. A previous attempt to derive meaning automatically (LLM search) was slow and low-quality, so the family is wary of auto-extraction from handwriting.
## Goal
A **hand-curated, year-banded vertical timeline** — the "Zeitstrahl" — that weaves three layers into one chronological view:
1. **Person life-events** derived from already-curated structured data (`Person` birth/death dates, marriage years from `PersonRelationship.fromYear`). Trusted, free, no extra entry. (Requires the Person birth/death fields to move from year-integers to date + precision — see foundational issue 1.)
2. **Hand-curated events** the family writes — both **personal** (a move, an illness, emigration) and **historical** (a war, hyperinflation). Editorially controlled, always correct.
3. **Letters**, auto-placed by their existing `documentDate`, optionally hand-linked to an event to cluster them.
Two surfaces, one component:
- **Global timeline** at `/zeitstrahl`.
- **Per-person "Lebensweg"** — the same view filtered to one person, embedded on the Person detail page.
Built for phones (vertical scroll), honest about date precision, with no fabricated dates.
### Non-goals (YAGNI)
- ❌ Auto-extracting events from transcription text — explicitly avoided; this is what makes the feature trustworthy.
- ❌ Importing an external historical-events dataset — historical events are hand-entered too.
- ❌ A map / geographic view — that is a separate future feature (B2).
- ❌ Per-derived-event hide/override toggle — deferred refinement; MVP shows all derived events.
- ❌ Day-resolution timeline axis — the axis is the **year**; finer dates only affect within-band ordering and label text.
## Core principle: the year is the axis
Most dates in the archive are year-only (birth/death/marriage years are years by nature; many letters carry `YEAR`/`APPROX` precision). Therefore:
- The timeline spine is a **sequence of year bands**. Everything for a given year lives in that band.
- **Finer ordering only when we have it.** A `DAY`-precision letter (`1923-04-12`) sorts above a `YEAR`-precision one (`1923`) *within* the 1923 band; we never invent a day we don't have.
- **An "Ohne Datum" bucket** at the end holds items with `UNKNOWN` precision.
- **Honest precision rendering** reuses the existing `DatePrecision` enum for every dated item (events and letters share one rendering path).
### Date rendering (shared by events and letters)
| `DatePrecision` | German render | Example |
|---|---|---|
| `DAY` | full date | `28. Juli 1914` |
| `MONTH` | month + year | `Juli 1914` |
| `SEASON` | season + year | `Sommer 1914` |
| `YEAR` | year only | `1914` |
| `APPROX` | "ca." + year | `ca. 1914` |
| `RANGE` | startend year | `19141918` |
| `UNKNOWN` | undated bucket | `Ohne Datum` |
A `RANGE` item is shown in its **start year's band** with a span marker; it is not duplicated across every year it covers.
## Data model
A new `timeline/` domain package on the backend (kept deliberately separate from the in-flight Lesereisen/`Geschichte` work in #750753).
### `TimelineEvent` entity
Mirrors the `Document` date model for consistency, so events and letters use one date-handling code path.
| Field | Type | Notes |
|---|---|---|
| `id` | `UUID` | `@GeneratedValue(UUID)` |
| `title` | `String` | required |
| `type` | `EventType` enum | `PERSONAL`, `HISTORICAL` |
| `eventDate` | `LocalDate` | required — most precise date known (WW1 → `1914-07-28`; vague year → `1920-01-01`) |
| `precision` | `DatePrecision` | reuse existing enum; default `YEAR` — governs rendering & whether the day matters |
| `eventDateEnd` | `LocalDate` (nullable) | only set when `precision == RANGE` |
| `description` | `TEXT` (nullable) | free-text narrative for the event |
| `persons` | ManyToMany `Person` | who the event involves; drives the per-person view & filtering |
| `documents` | ManyToMany `Document` | optional hand-linked supporting letters (the "cluster letters to an event" feature) |
| `createdBy` / `createdAt` / `updatedBy` / `updatedAt` / `version` | audit | standard entity conventions |
- `@Schema(requiredMode = REQUIRED)` on every always-populated field (`id`, `title`, `type`, `eventDate`, `precision`).
- Collections use `@Builder.Default new HashSet<>()`.
- New Flyway migration adds `timeline_events`, `timeline_event_persons`, `timeline_event_documents` join tables.
### `EventType` enum
`PERSONAL` | `HISTORICAL`. Personal events render with a person/family accent; historical events with a "world" accent and muted styling so the two layers are visually separable.
### Prerequisite: migrate `Person` birth/death to date + precision
Today `Person` stores `birthYear`/`deathYear` as `Integer`, so a known exact birthday (e.g. `1901-03-14`) has nowhere to live and derived events are stuck at year precision. This is fixed by a **foundational Person-domain migration** that the timeline depends on (and which delivers value on its own — precise dates then render on person cards, hover cards, and the Stammbaum).
**Change:** replace `birthYear`/`deathYear` (`Integer`) with:
| Field | Type | Notes |
|---|---|---|
| `birthDate` | `LocalDate` (nullable) | most precise date known |
| `birthDatePrecision` | `DatePrecision` (nullable) | `YEAR` for year-only, `DAY` for exact birthdays, etc. |
| `deathDate` | `LocalDate` (nullable) | |
| `deathDatePrecision` | `DatePrecision` (nullable) | |
**Flyway data migration:** existing `birth_year``birth_date = '{year}-01-01'`, `birth_date_precision = 'YEAR'` (same for death); then drop the year columns.
**Re-import preservation (ADR-025):** the canonical importer (`PersonRegisterImporter` / `tools/import-normalizer/persons_tree.py`) only carries the *year*. On re-import it must **not** clobber a hand-entered finer-than-`YEAR` date — if the existing precision is `DAY`/`MONTH`/`SEASON`, preserve it; only refresh from the spreadsheet year when the field is empty or still `YEAR`-from-import.
**Bounding the blast radius:** `PersonNodeDTO` keeps exposing an `Integer birthYear`/`deathYear` *derived* from the new date (`birthDate.getYear()`), so the Stammbaum layout (`familyForest.ts` et al.) is untouched. Display surfaces (person card, hover card) move to a shared precision-aware formatter — extend the existing `frontend/src/lib/person/personLifeDates.ts`. The person edit/new forms gain date inputs with a precision selector.
**Scope note:** `PersonRelationship.fromYear` (marriage year) stays `Integer`/`YEAR` for MVP — precise marriage dates are a later, parallel extension if wanted.
### Derived person-events (not persisted)
Assembled on read from the migrated `Person` data; never stored:
| Source | Derived event | `eventDate` | precision |
|---|---|---|---|
| `Person.birthDate` | *Geburt: {name}* | `Person.birthDate` | `Person.birthDatePrecision` |
| `Person.deathDate` | *Tod: {name}* | `Person.deathDate` | `Person.deathDatePrecision` |
| `PersonRelationship` `SPOUSE_OF.fromYear` | *Heirat: {A} & {B}* | `{fromYear}-01-01` | `YEAR` |
Emitted in the same DTO shape as a curated event, flagged `derived: true`, `type = PERSONAL`. They cannot be edited from the timeline (they are edited at their source: Person record / relationship). A marriage is derived once per `SPOUSE_OF` edge (symmetric edges are stored once — see existing relationship rules).
### Letters
Placed by `Document.documentDate`:
- Band = `documentDate.getYear()`; `UNKNOWN` precision → "Ohne Datum" bucket.
- Sub-ordered within a band by full date when precision allows.
- A letter may also appear under an event it's linked to (via `TimelineEvent.documents`) as a cluster, in addition to its own band placement.
## Assembly & API
A `TimelineService` merges the three layers into a year-bucketed DTO for the requested scope and filters. Layering rules apply: the service owns `TimelineEventRepository` and reaches Person/Document/Relationship data through their **services**, never their repositories.
### DTOs
- `TimelineEntryDTO` — one renderable item: `kind` (`EVENT` | `LETTER`), `eventDate`, `precision`, `eventDateEnd`, `title`, `type` (for events), `derived` flag, plus the source id (eventId / documentId) and minimal display fields (sender/receiver names for letters, linked person ids for events).
- `TimelineYearDTO``{ year: int, entries: TimelineEntryDTO[] }`.
- `TimelineDTO``{ years: TimelineYearDTO[], undated: TimelineEntryDTO[] }`.
### Endpoints
- `GET /api/timeline` — global timeline. Query params (all optional): `personId`, `generation`, `type` (`PERSONAL`/`HISTORICAL`), `fromYear`, `toYear`. The per-person "Lebensweg" is just `GET /api/timeline?personId=…` — no separate endpoint. Requires `READ_ALL`.
- `POST /api/timeline/events` — create a curated event. `@RequirePermission(Permission.WRITE_ALL)`.
- `PUT /api/timeline/events/{id}` — update. `@RequirePermission(Permission.WRITE_ALL)`.
- `DELETE /api/timeline/events/{id}` — delete. `@RequirePermission(Permission.WRITE_ALL)`.
- `GET /api/timeline/events/{id}` — fetch a single event for the edit form. Requires `READ_ALL`.
Input DTO `TimelineEventRequest` lives flat in the `timeline/` package. Errors use `DomainException.notFound/...`; **no new `ErrorCode`** is required. Run `npm run generate:api` after backend model/endpoint changes.
## Frontend
- New domain dir `frontend/src/lib/timeline/`:
- `TimelineView.svelte` — orchestrator; accepts an optional `personId` prop so the same component powers both global and per-person views.
- `YearBand.svelte` — one year section header + its entries.
- `EventCard.svelte` — renders a `PERSONAL`/`HISTORICAL`/derived event with precision-aware date label.
- `LetterCard.svelte` — compact letter row (sender → receiver, snippet/title, date), links to `/documents/[id]`.
- `TimelineFilters.svelte` — person, generation, layer toggles, year range.
- `dateLabel.ts` — the shared precision→label helper (reuse/extend `lib/document/timeline.ts` helpers like `formatTickLabel` where they fit).
- Routes:
- `/zeitstrahl` — global timeline (`+page.server.ts` loads `/api/timeline`).
- `/zeitstrahl/events/new` and `/zeitstrahl/events/[id]/edit` — curator forms, gated to `WRITE_ALL`, using the form-actions pattern.
- Person detail page gains a **Lebensweg** card section embedding `<TimelineView personId={person.id} />`.
- Styling per project conventions (card pattern, brand tokens, `font-serif` for names/titles, `BackButton`, mobile-first at 375px, dark-mode tokens).
- i18n keys added to `messages/{de,en,es}.json` (German primary).
## Testing
- Backend: `TimelineService` assembly/merge/sort/precision-bucketing (unit + `@DataJpaTest` against Postgres via Testcontainers); controller permission gating; derived-event assembly (birth/death/marriage, symmetric marriage dedup).
- Frontend: `dateLabel.ts` precision rendering; `TimelineView` global vs `personId` modes (`*.svelte.spec.ts`); filter behavior.
- Follow project test discipline: targeted single-file runs locally only; full sweep left to CI.
## Proposed issue breakdown (milestone "Zeitstrahl / Family Timeline")
Ordered so each issue is independently shippable and reviewable; later issues depend on earlier ones. Issue 1 is a standalone Person-domain improvement and a hard prerequisite for the timeline's derived events.
1. **Person birth/death → date + precision (foundational)** — replace `birthYear`/`deathYear` with `birthDate`/`deathDate` + precision on `Person`; Flyway data migration (year → `YYYY-01-01`, `YEAR`); update importer with re-import preservation rule; derive year in `PersonNodeDTO` (Stammbaum untouched); move person card / hover card to a precision-aware `personLifeDates.ts`; add date+precision inputs to person new/edit forms. Ships value on its own.
2. **Backend: `TimelineEvent` entity + migration** — entity, `EventType`, Flyway migration + join tables, repository.
3. **Backend: TimelineEvent CRUD API**`TimelineEventController` + `TimelineService` write methods, `TimelineEventRequest` DTO, permission gating, `GET /events/{id}`.
4. **Backend: derived person-events** — assemble Geburt/Tod/Heirat from migrated Person + relationship data via their services; unit-tested dedup.
5. **Backend: timeline assembly endpoint**`GET /api/timeline` merging events + derived events + letters into `TimelineDTO`; year-bucketing, precision sort, undated bucket, filters.
6. **Frontend: shared date-label helper + types**`dateLabel.ts`, regen API types.
7. **Frontend: global `/zeitstrahl` view**`TimelineView`, `YearBand`, `EventCard`, `LetterCard`, server load.
8. **Frontend: filters**`TimelineFilters` (person / generation / layer / year range).
9. **Frontend: curator event forms**`/zeitstrahl/events/new` + `/[id]/edit`, gated, with document & person pickers.
10. **Frontend: per-person Lebensweg** — embed `<TimelineView personId>` on Person detail.
11. **Polish & a11y** — mobile layout at 375px, dark mode, axe checks, i18n completeness (de/en/es).
> An ADR may be warranted for the new `timeline/` domain + entity (per `docs/CLAUDE.md`, significant data-model change). Add as the next sequential ADR number when implementation starts.

3
frontend/.gitignore vendored
View File

@@ -13,6 +13,9 @@ node_modules
.DS_Store
Thumbs.db
# Leftover directory from branch work
/src.main/
# Env
.env
.env.*

View File

@@ -7,6 +7,7 @@ bun.lockb
# Miscellaneous
/static/
/src.main/
# Build artifacts
/.svelte-kit/
@@ -19,6 +20,7 @@ bun.lockb
/src/lib/paraglide/
/src/lib/paraglide_bak*/
/src/paraglide/
/src.main/
/project.inlang/
# Test artifacts

View File

@@ -28,7 +28,6 @@ src/
│ ├── +layout.server.ts # Loads current user, injects auth cookie
│ ├── +page.svelte # Home / document search dashboard
│ ├── documents/ # Document CRUD, detail, edit, upload
│ ├── search/ # Smart (NL) search sub-components — SmartModeToggle, InterpretationChipRow, SmartSearchStatus, DisambiguationPicker (no +page; consumed by documents/ and SearchFilterBar)
│ ├── persons/ # Person directory (filtered, paginated), detail, edit, merge, review (triage)
│ ├── aktivitaeten/ # Unified activity feed (Chronik)
│ ├── admin/ # User, group, tag, OCR, system management

View File

@@ -1,113 +0,0 @@
import AxeBuilder from '@axe-core/playwright';
import { test, expect } from '@playwright/test';
// NL search is mocked at the network boundary — Ollama is not required in CI.
// CSRF enforcement is bypassed by page.route (the real request is never sent),
// so it is only verified in manual full-stack runs (see issue #739 DevOps notes).
const interpretation = {
resolvedPersons: [
{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter Raddatz' },
{ id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma Raddatz' }
],
ambiguousPersons: [],
dateFrom: '1914-01-01',
dateTo: '1918-12-31',
keywords: ['krieg'],
resolvedTags: [{ id: '33333333-3333-3333-3333-333333333333', name: 'Weltkrieg', color: 'sage' }],
rawQuery: 'Was hat Walter an Emma im Krieg geschrieben?',
keywordsApplied: true,
tagsApplied: true
};
const nlResponse = {
result: {
items: [],
totalElements: 0,
pageNumber: 0,
pageSize: 20,
totalPages: 0,
undatedCount: 0
},
interpretation
};
test.describe('NL (smart) search — happy path', () => {
test('toggle → loading → chips → remove chip re-runs keyword search; axe clean light + dark', async ({
page
}) => {
// Deliberate delay so the loading state is assertable before the response arrives.
await page.route('**/api/search/nl', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 150));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(nlResponse)
});
});
await page.goto('/documents');
await page.waitForSelector('[data-hydrated]');
// Switch to smart mode via the toggle pill (keyword label = "Text").
await page.getByRole('button', { name: /Text/ }).click();
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
await input.fill('Was hat Walter an Emma im Krieg geschrieben?');
await input.press('Enter');
// Loading panel announced to screen readers.
await expect(page.getByText(/Archiv wird befragt/)).toBeVisible();
// Directional chip (Walter → Emma) + keyword chip + theme chip render once the fixture resolves.
await expect(page.getByText('→')).toBeVisible();
await expect(page.getByText('Stichwort: krieg')).toBeVisible();
await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
// Accessibility — light mode.
const lightScan = await new AxeBuilder({ page })
.include('[data-testid="smart-search-results"]')
.analyze();
expect(lightScan.violations).toEqual([]);
// Accessibility — dark mode.
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
const darkScan = await new AxeBuilder({ page })
.include('[data-testid="smart-search-results"]')
.analyze();
expect(darkScan.violations).toEqual([]);
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
// Removing the keyword chip re-runs a keyword GET with the remaining resolved
// params (sender + receiver from the directional pair).
await page.getByRole('button', { name: 'Filter entfernen: Stichwort: krieg' }).click();
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/);
});
test('removing the last theme chip drops tag/tagOp but keeps person params', async ({ page }) => {
await page.route('**/api/search/nl', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(nlResponse)
});
});
await page.goto('/documents');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: /Text/ }).click();
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
await input.fill('Was hat Walter an Emma im Krieg geschrieben?');
await input.press('Enter');
await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
// Remove the single theme chip — URL must carry sender UUID but no tag/tagOp.
await page.getByRole('button', { name: 'Filter entfernen: Thema: Weltkrieg' }).click();
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
const url = page.url();
expect(url).not.toMatch(/tag=/);
expect(url).not.toMatch(/tagOp=/);
});
});

View File

@@ -22,33 +22,6 @@
"error_forbidden": "Sie haben keine Berechtigung für diese Aktion.",
"error_csrf_token_missing": "Sitzungsfehler. Bitte laden Sie die Seite neu.",
"error_too_many_login_attempts": "Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.",
"error_smart_search_unavailable": "Die intelligente Suche ist momentan nicht verfügbar. Bitte nutzen Sie die normale Suche.",
"error_smart_search_rate_limited": "Sie haben die Suchfunktion zu häufig genutzt. Bitte warten Sie eine Minute.",
"smart_search_keywords_not_applied": "Schlüsselwörter konnten bei dieser Suche nicht berücksichtigt werden.",
"search_toggle_smart_label": "KI",
"search_toggle_smart_label_suffix": "-Suche",
"search_toggle_keyword_label": "Text",
"search_toggle_keyword_label_suffix": "suche",
"search_loading_nl": "Archiv wird befragt…",
"search_loading_nl_sub": "Die KI analysiert Ihre Anfrage. Das kann bis zu 15 Sekunden dauern.",
"search_error_unavailable": "Intelligente Suche nicht verfügbar",
"search_error_unavailable_body": "Die KI-Suche ist momentan nicht erreichbar. Sie können Ihre Anfrage als einfache Volltextsuche wiederholen.",
"search_switch_to_keyword": "Zur Volltextsuche wechseln",
"search_error_rate_limited": "Zu viele Anfragen",
"search_error_rate_limited_body": "Sie haben die intelligente Suche zu häufig genutzt. Bitte warten Sie eine Minute und versuchen Sie es erneut.",
"search_empty_nl": "Keine Ergebnisse",
"search_empty_retry_keyword": "Als Volltextsuche wiederholen",
"search_filter_remove_label": "Filter entfernen: {label}",
"search_chip_sender": "Absender",
"search_chip_date": "Zeitraum",
"search_chip_keyword": "Stichwort",
"search_chip_theme_prefix": "Thema",
"search_chip_directional_label": "Von {from} zu {to}, Filter entfernen",
"search_disambiguation_trigger_label": "Mehrere Personen gefunden — zum Auswählen klicken",
"search_disambiguation_cue": "(auswählen…)",
"search_disambiguation_heading": "Person auswählen",
"search_disambiguation_did_you_mean": "Meintest du {name}?",
"search_disambiguation_select_label": "{name} auswählen",
"error_validation_error": "Die Eingabe ist ungültig.",
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
"nav_documents": "Dokumente",
@@ -328,6 +301,8 @@
"comp_multiselect_placeholder": "Namen tippen...",
"comp_multiselect_remove": "Entfernen",
"comp_multiselect_loading": "Suche...",
"comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
"comp_typeahead_no_results": "Keine Treffer",
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
"comp_taginput_remove": "Schlagwort entfernen",
@@ -1050,6 +1025,10 @@
"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.",
"journey_item_document_deleted": "[Dokument gelöscht]",
"geschichten_index_title": "Geschichten",
"geschichten_new_button": "Neue Geschichte",
"geschichten_filter_all_pill": "Alle",
@@ -1060,10 +1039,15 @@
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
"geschichten_filter_document_chip": "Gefiltert nach Brief:",
"geschichten_filter_remove_document_chip": "Brief {title} aus Filter entfernen",
"geschichten_empty_for_document": "Noch keine Geschichten zu diesem Brief",
"geschichten_back_to_index": "Zurück zu Geschichten",
"geschichten_published_on": "veröffentlicht am {date}",
"journey_compiled_on": "zusammengestellt 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",
@@ -1071,6 +1055,7 @@
"geschichten_card_show_all": "Alle anzeigen",
"geschichte_editor_title_placeholder": "Titel der Geschichte",
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
"geschichte_sidebar_status": "Status",
"geschichte_editor_status_draft": "ENTWURF",
"geschichte_editor_status_published": "VERÖFFENTLICHT",
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
@@ -1085,8 +1070,17 @@
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
"geschichte_editor_personen_heading": "Personen",
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
"geschichte_editor_dokumente_heading": "Dokumente",
"geschichte_editor_dokumente_hint": "Welche Briefe oder Dokumente sind Teil dieser Geschichte?",
"geschichte_documents_heading": "Briefe & Dokumente",
"geschichte_documents_hint": "Welche Dokumente gehören zu dieser Geschichte?",
"geschichte_documents_empty": "Noch keine Dokumente verknüpft. Suche unten nach einem Brief, um ihn dieser Geschichte hinzuzufügen.",
"geschichte_documents_picker_label": "Dokument hinzufügen",
"geschichte_documents_picker_placeholder": "Brief oder Dokument suchen…",
"geschichte_documents_deleted_placeholder": "Dokument wurde gelöscht",
"geschichte_documents_remove_label": "Dokument entfernen: {title}",
"geschichte_documents_capacity": "Diese Geschichte hat bereits die maximale Anzahl von Dokumenten (100) erreicht.",
"geschichte_documents_duplicate": "Dieses Dokument ist bereits mit der Geschichte verknüpft.",
"geschichte_documents_added_announce": "Hinzugefügt: {title}",
"geschichte_documents_removed_announce": "Entfernt: {title}",
"geschichte_editor_search_person": "Person suchen…",
"geschichte_editor_search_document": "Dokument suchen…",
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
@@ -1180,5 +1174,58 @@
"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_create_submit": "Lesereise erstellen",
"journey_item_open_aria": "Brief vom {date} öffnen",
"journey_item_open_aria_undated": "Brief öffnen",
"journey_item_open": "Brief öffnen",
"journey_item_meta_from_to": "von {sender} an {receiver}",
"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.",
"journey_add_document": "Brief hinzufügen",
"journey_add_interlude": "Zwischentext hinzufügen",
"journey_interlude_label": "Zwischentext",
"journey_item_pending_remove": "wird entfernt…",
"journey_publish_disabled_hint": "Titel und mindestens ein Eintrag erforderlich.",
"journey_title_aria_label": "Titel der Lesereise",
"journey_intro_aria_label": "Einleitung der Lesereise",
"journey_note_add": "Notiz hinzufügen",
"journey_note_remove": "Notiz entfernen",
"journey_note_save_hint": "Wird gespeichert, wenn du das Feld verlässt.",
"journey_intro_save_hint": "Wird mit 'Speichern' gesichert.",
"journey_already_added": "Bereits enthalten",
"journey_note_aria_label": "Kuratoren-Notiz für {title}",
"journey_move_up": "'{title}' nach oben verschieben",
"journey_move_down": "'{title}' nach unten verschieben",
"journey_note_error": "Notiz konnte nicht gespeichert werden",
"journey_item_moved": "Eintrag {position} von {total} — nach Position {newPosition} verschoben",
"journey_remove_item_aria": "'{title}' entfernen",
"journey_remove_confirm": "Wirklich entfernen?",
"journey_remove_confirm_yes": "Bestätigen",
"journey_remove_confirm_cancel": "Abbrechen",
"journey_mutation_error_reload": "Aktion fehlgeschlagen bitte Seite neu laden.",
"journey_published_empty_warning": "Diese Reise wird ohne Einträge veröffentlicht bleiben.",
"journey_intro_placeholder": "Einleitung (optional)",
"journey_interlude_placeholder": "Zwischentext eingeben…",
"journey_add_interlude_confirm": "Hinzufügen",
"journey_edit_title_story": "Geschichte bearbeiten",
"journey_edit_title_journey": "Lesereise bearbeiten",
"journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich",
"journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.",
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
"person_unknown": "[Unbekannt]",
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
}

Some files were not shown because too many files have changed in this diff Show More