Compare commits
143 Commits
worktree-f
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee055e615 | ||
|
|
9be24f2613 | ||
|
|
d5441d3e55 | ||
|
|
c131507e30 | ||
|
|
c50f04bafa | ||
|
|
f004b1f2a6 | ||
|
|
75de56928e | ||
|
|
6ed8ecf571 | ||
|
|
4c75680977 | ||
|
|
930f69e884 | ||
|
|
eea8e6bf5a | ||
|
|
55e3e4c531 | ||
|
|
7a5c2d0ba3 | ||
|
|
994772564a | ||
|
|
a0930b62b0 | ||
|
|
3572de487a | ||
|
|
f9cdc02a77 | ||
|
|
4c24bbb002 | ||
|
|
91d9dae6fd | ||
|
|
4184d0775b | ||
|
|
97026fec11 | ||
|
|
565eddd743 | ||
|
|
0b9e8c2abb | ||
|
|
8a6bc27979 | ||
|
|
8fea94cb61 | ||
|
|
0d47bcb4a1 | ||
|
|
825a622413 | ||
|
|
81a12ba35c | ||
| 0780c09bb4 | |||
|
|
77cbbd34a0 | ||
|
|
84b47f1836 | ||
|
|
99111273e5 | ||
|
|
f09c79744e | ||
|
|
1108277472 | ||
|
|
9db3b41fdb | ||
|
|
73004ce49f | ||
|
|
c31f82a69c | ||
|
|
f9ae6a91ba | ||
|
|
70da532f54 | ||
|
|
ad90ae75bf | ||
|
|
164178ecf1 | ||
|
|
3f36d2a7f1 | ||
|
|
5b2ee31292 | ||
|
|
3d80bc656c | ||
|
|
4a0fed617a | ||
|
|
7ba6342a84 | ||
|
|
598ad622e7 | ||
|
|
c5611250ec | ||
|
|
e400b1d77e | ||
|
|
1fb0c41216 | ||
|
|
147aa56386 | ||
|
|
7c06609816 | ||
|
|
2ae1bb3a30 | ||
|
|
69db198319 | ||
|
|
e157d90b53 | ||
|
|
97f22e1ce8 | ||
|
|
5539158e8f | ||
|
|
7ed0032661 | ||
|
|
2f471155b8 | ||
|
|
4eb6abd920 | ||
|
|
6fc5ce6ddd | ||
|
|
4603e335fd | ||
|
|
a0fa8f4d02 | ||
|
|
d29f217328 | ||
|
|
fdc9273c86 | ||
|
|
2ad5c36e3c | ||
|
|
160ca1c3e9 | ||
|
|
7b06c3adec | ||
|
|
408ae3345c | ||
|
|
0b17724785 | ||
|
|
df5d880e09 | ||
|
|
45500cc5e2 | ||
|
|
b1819867be | ||
|
|
2c5f7ac12d | ||
|
|
13fa4123c1 | ||
|
|
ccdf358b40 | ||
|
|
e6c890c61e | ||
|
|
93ef26690f | ||
|
|
439385dd35 | ||
|
|
b3ce9b930f | ||
| d650b6c066 | |||
|
|
8e63867ad8 | ||
|
|
6b0a06e8b1 | ||
|
|
7c1eef710c | ||
|
|
03e22a2f26 | ||
|
|
6878419156 | ||
|
|
09b77e9b36 | ||
|
|
9d202b042b | ||
|
|
8429b1e9f8 | ||
|
|
6959651b36 | ||
|
|
0ef4f4f07c | ||
|
|
f1bb9d3a69 | ||
|
|
ca52145556 | ||
|
|
9a26bf75b0 | ||
|
|
9c616f9fb8 | ||
|
|
0fe0ae5235 | ||
|
|
2c909f49a8 | ||
|
|
87fd0f39bb | ||
|
|
7f3ad8ce89 | ||
|
|
aa1f6436cc | ||
|
|
b825076733 | ||
|
|
01df815bad | ||
|
|
dcd0e725a7 | ||
|
|
39ff63921d | ||
|
|
5a09cd4cb4 | ||
|
|
4e0ebc72c8 | ||
|
|
0f0d89702d | ||
|
|
fb41affd4c | ||
|
|
dc366ed403 | ||
|
|
64b7b2315d | ||
|
|
2a7e133717 | ||
|
|
5387bc9247 | ||
|
|
847874abb3 | ||
|
|
573bca4986 | ||
|
|
86690fdbb6 | ||
|
|
6cb1025881 | ||
|
|
fc557bd9ae | ||
|
|
e94414b81a | ||
|
|
7eee688ce9 | ||
|
|
8905135006 | ||
|
|
8bd8390891 | ||
|
|
ed98729f75 | ||
|
|
db87a64cc0 | ||
|
|
d7d6d0638c | ||
|
|
a2f37f85a6 | ||
|
|
f22a1a1cfa | ||
|
|
2a0863cf3e | ||
|
|
9e97687d0f | ||
|
|
b665e1132d | ||
|
|
87af9ab446 | ||
|
|
0058b297d8 | ||
|
|
230f23e37c | ||
|
|
e604967a3f | ||
|
|
169e1ad9de | ||
|
|
f2f42ed415 | ||
|
|
5945824b54 | ||
|
|
fa41394e66 | ||
|
|
fb00c7818e | ||
|
|
8ed65f8602 | ||
|
|
9e425c98a1 | ||
|
|
ddce268113 | ||
| 4a43962c98 | |||
|
|
62c8ce4cb2 |
24
CLAUDE.md
24
CLAUDE.md
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
### 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -50,10 +50,25 @@ public enum AuditKind {
|
||||
ADMIN_FORCE_LOGOUT,
|
||||
|
||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||
LOGIN_RATE_LIMITED;
|
||||
LOGIN_RATE_LIMITED,
|
||||
|
||||
// --- Reading Journeys (Lesereisen) ---
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
|
||||
JOURNEY_ITEM_ADDED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_REMOVED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_NOTE_UPDATED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
|
||||
JOURNEY_ITEMS_REORDERED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
|
||||
JOURNEY_ITEMS_REORDERED
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1006,6 +1006,28 @@ public class DocumentService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight summary lookup for internal use (e.g. journey item append validation).
|
||||
*
|
||||
* <p><strong>Security contract — read before calling:</strong>
|
||||
* <ol>
|
||||
* <li>This method intentionally bypasses per-document scope checks and
|
||||
* tag-colour resolution. It must only be invoked after
|
||||
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
|
||||
* the controller layer, guaranteeing the caller is an authenticated
|
||||
* author.</li>
|
||||
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
|
||||
* JOURNEY-type check that fires before this call — so the method is never
|
||||
* reached for STORY-type Geschichten.</li>
|
||||
* </ol>
|
||||
* Under the current single-tenant model every authenticated author shares the
|
||||
* same document scope, so skipping per-document scope checks is safe.
|
||||
*/
|
||||
public Document findSummaryByIdInternal(UUID id) {
|
||||
return documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a document for the detail view, additionally flagging whether it has any
|
||||
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
||||
|
||||
@@ -122,6 +122,14 @@ public enum ErrorCode {
|
||||
// --- Geschichten (Stories) ---
|
||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||
GESCHICHTE_NOT_FOUND,
|
||||
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
|
||||
JOURNEY_ITEM_NOT_FOUND,
|
||||
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
|
||||
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||
JOURNEY_AT_CAPACITY,
|
||||
/** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */
|
||||
GESCHICHTE_TYPE_MISMATCH,
|
||||
|
||||
// --- Tags ---
|
||||
/** A tag with the given ID does not exist. 404 */
|
||||
@@ -135,12 +143,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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -28,23 +31,22 @@ import java.util.UUID;
|
||||
public class GeschichteController {
|
||||
|
||||
private final GeschichteService geschichteService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
@GetMapping
|
||||
public List<Geschichte> list(
|
||||
public List<GeschichteSummary> list(
|
||||
@RequestParam(required = false) GeschichteStatus status,
|
||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||
@RequestParam(required = false) UUID documentId,
|
||||
@RequestParam(required = false, defaultValue = "50") int limit) {
|
||||
return geschichteService.list(
|
||||
status,
|
||||
personIds == null ? List.of() : personIds,
|
||||
documentId,
|
||||
limit);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Geschichte getById(@PathVariable UUID id) {
|
||||
return geschichteService.getById(id);
|
||||
public GeschichteView getById(@PathVariable UUID id) {
|
||||
return geschichteService.getView(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -66,4 +68,45 @@ public class GeschichteController {
|
||||
geschichteService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{id}/items")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<JourneyItemView> appendItem(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyItemCreateDTO dto) {
|
||||
JourneyItemView view = journeyItemService.append(id, dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(view);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public JourneyItemView updateItemNote(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId,
|
||||
@RequestBody JourneyItemUpdateDTO dto) {
|
||||
return journeyItemService.updateNote(id, itemId, dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Void> deleteItem(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId) {
|
||||
journeyItemService.delete(id, itemId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/items/reorder")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
@Operation(
|
||||
summary = "Reorder journey items",
|
||||
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
|
||||
)
|
||||
public List<JourneyItemView> reorderItems(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyReorderDTO dto) {
|
||||
return journeyItemService.reorder(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,43 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
||||
|
||||
/**
|
||||
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
|
||||
*
|
||||
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
|
||||
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
|
||||
*
|
||||
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
|
||||
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
|
||||
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
|
||||
g.author AS author, g.publishedAt AS publishedAt, g.body AS body
|
||||
FROM Geschichte g
|
||||
WHERE g.status = :effectiveStatus
|
||||
AND (:authorId IS NULL OR g.author.id = :authorId)
|
||||
AND (:personCount = 0 OR
|
||||
(SELECT COUNT(DISTINCT p.id)
|
||||
FROM Geschichte g2 JOIN g2.persons p
|
||||
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
|
||||
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
|
||||
""")
|
||||
List<GeschichteSummary> findSummaries(
|
||||
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
|
||||
@Param("authorId") UUID authorId,
|
||||
@Param("personIds") Collection<UUID> personIds,
|
||||
@Param("personCount") long personCount);
|
||||
}
|
||||
|
||||
@@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.owasp.html.HtmlPolicyBuilder;
|
||||
import org.owasp.html.PolicyFactory;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -41,6 +36,7 @@ public class GeschichteService {
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final UserService userService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
/**
|
||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||
@@ -60,6 +56,7 @@ public class GeschichteService {
|
||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Geschichte getById(UUID id) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
@@ -72,24 +69,57 @@ public class GeschichteService {
|
||||
return g;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public GeschichteView getView(UUID id) {
|
||||
Geschichte g = getById(id);
|
||||
List<JourneyItemView> items = journeyItemService.getItems(id);
|
||||
return toView(g, items);
|
||||
}
|
||||
|
||||
GeschichteView toView(Geschichte g, List<JourneyItemView> items) {
|
||||
AppUser author = g.getAuthor();
|
||||
GeschichteView.AuthorView authorView = null;
|
||||
if (author != null) {
|
||||
String displayName = PersonNameFormatter.join(author.getFirstName(), author.getLastName());
|
||||
if (displayName.isBlank()) displayName = "[Unbekannt]";
|
||||
authorView = new GeschichteView.AuthorView(author.getId(), displayName);
|
||||
}
|
||||
Set<GeschichteView.PersonView> personViews = new HashSet<>();
|
||||
for (Person p : g.getPersons()) {
|
||||
personViews.add(new GeschichteView.PersonView(p.getId(), p.getFirstName(), p.getLastName()));
|
||||
}
|
||||
return new GeschichteView(
|
||||
g.getId(), g.getTitle(), g.getBody(),
|
||||
g.getStatus(), g.getType(),
|
||||
authorView, personViews,
|
||||
items,
|
||||
g.getPublishedAt(), g.getCreatedAt(), g.getUpdatedAt()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
|
||||
* must be associated with every person id supplied. An empty or null list applies no
|
||||
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
|
||||
*
|
||||
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
|
||||
* LazyInitializationException on the non-transactional list path.
|
||||
*/
|
||||
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, int limit) {
|
||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
|
||||
|
||||
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
|
||||
Specification<Geschichte> spec = Specification.allOf(
|
||||
GeschichteSpecifications.hasStatus(effective),
|
||||
GeschichteSpecifications.hasAuthor(authorId),
|
||||
GeschichteSpecifications.hasAllPersons(personIds),
|
||||
GeschichteSpecifications.hasDocument(documentId),
|
||||
GeschichteSpecifications.orderByDisplayDateDesc()
|
||||
);
|
||||
return geschichteRepository.findAll(spec, Sort.unsorted())
|
||||
|
||||
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
|
||||
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
|
||||
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
|
||||
? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
|
||||
: personIds;
|
||||
long personCount = (personIds == null) ? 0 : personIds.size();
|
||||
|
||||
return geschichteRepository
|
||||
.findSummaries(effective, authorId, safePersonIds, personCount)
|
||||
.stream()
|
||||
.limit(safeLimit)
|
||||
.toList();
|
||||
@@ -106,7 +136,6 @@ public class GeschichteService {
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.author(currentUser())
|
||||
.persons(resolvePersons(dto.getPersonIds()))
|
||||
.documents(resolveDocuments(dto.getDocumentIds()))
|
||||
.build();
|
||||
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||
@@ -130,9 +159,6 @@ public class GeschichteService {
|
||||
if (dto.getPersonIds() != null) {
|
||||
g.setPersons(resolvePersons(dto.getPersonIds()));
|
||||
}
|
||||
if (dto.getDocumentIds() != null) {
|
||||
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
||||
}
|
||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||
applyStatusTransition(g, dto.getStatus());
|
||||
}
|
||||
@@ -176,15 +202,6 @@ public class GeschichteService {
|
||||
return new LinkedHashSet<>(personService.getAllById(ids));
|
||||
}
|
||||
|
||||
private Set<Document> resolveDocuments(List<UUID> ids) {
|
||||
if (ids == null || ids.isEmpty()) return new HashSet<>();
|
||||
Set<Document> out = new LinkedHashSet<>();
|
||||
for (UUID id : ids) {
|
||||
out.add(documentService.getDocumentById(id));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private AppUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* List-projection for the /api/geschichten grid. Never carries items — avoids
|
||||
* LazyInitializationException (open-in-view: false) and prevents Cartesian joins.
|
||||
* Mirrors the PersonSummaryDTO precedent.
|
||||
*
|
||||
* <p>Field set: exactly what the live grid card renders (title, author byline, body excerpt,
|
||||
* publishedAt, status, type). Does NOT carry items or persons.
|
||||
*/
|
||||
public interface GeschichteSummary {
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID getId();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String getTitle();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
GeschichteStatus getStatus();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
GeschichteType getType();
|
||||
|
||||
/** Nested closed projection — exposes only the fields the grid card needs. */
|
||||
AuthorSummary getAuthor();
|
||||
|
||||
LocalDateTime getPublishedAt();
|
||||
|
||||
String getBody();
|
||||
|
||||
interface AuthorSummary {
|
||||
String getFirstName();
|
||||
String getLastName();
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String getEmail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
public enum GeschichteType {
|
||||
STORY,
|
||||
JOURNEY
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -17,5 +16,4 @@ public class GeschichteUpdateDTO {
|
||||
private String body;
|
||||
private GeschichteStatus status;
|
||||
private List<UUID> personIds;
|
||||
private List<UUID> documentIds;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "journey_items")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class JourneyItem {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "geschichte_id", nullable = false)
|
||||
@JsonIgnore
|
||||
private Geschichte geschichte;
|
||||
|
||||
// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order
|
||||
// — the editor is responsible for keeping them distinct.
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int position;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "document_id")
|
||||
@JsonIgnore
|
||||
private Document document;
|
||||
|
||||
// CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email
|
||||
// renderer MUST escape this; only Svelte {note} is auto-safe.
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String note;
|
||||
|
||||
// JPA uses field access — this getter is not persisted. Jackson serializes it as documentId.
|
||||
// Exposing only the UUID prevents circular references and large nested payloads.
|
||||
public UUID getDocumentId() {
|
||||
return document != null ? document.getId() : null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
|
||||
|
||||
/** Returns items ordered by position ASC for the read-model assembly path. */
|
||||
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
|
||||
|
||||
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
|
||||
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
|
||||
|
||||
/** Returns only the IDs — used for set-equality check in reorder. */
|
||||
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** MAX position for computing the next append position; returns empty when journey has no items. */
|
||||
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
||||
long countByGeschichteId(UUID geschichteId);
|
||||
|
||||
/**
|
||||
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
||||
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
||||
* lazily for each item. Items without a document (note-only) are included via
|
||||
* LEFT JOIN. Ordered by position ASC.
|
||||
*/
|
||||
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
|
||||
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JourneyItemService {
|
||||
|
||||
static final int MAX_ITEMS = 100;
|
||||
static final int POSITION_STEP = 10;
|
||||
static final int MAX_NOTE_LENGTH = 5000;
|
||||
|
||||
private final JourneyItemRepository journeyItemRepository;
|
||||
private final GeschichteQueryService geschichteQueryService;
|
||||
private final DocumentService documentService;
|
||||
private final AuditService auditService;
|
||||
private final UserService userService;
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
|
||||
Geschichte g = geschichteQueryService.findById(geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Journey not found: " + geschichteId));
|
||||
|
||||
if (g.getType() != GeschichteType.JOURNEY) {
|
||||
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_MISMATCH,
|
||||
"Journey items can only be added to a JOURNEY-type Geschichte");
|
||||
}
|
||||
|
||||
long count = journeyItemRepository.countByGeschichteId(geschichteId);
|
||||
if (count >= MAX_ITEMS) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
|
||||
"Journey has reached the maximum of 100 items");
|
||||
}
|
||||
|
||||
String note = normalizeNote(dto.getNote());
|
||||
|
||||
if (dto.getDocumentId() == null && note == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"At least one of documentId or note must be provided");
|
||||
}
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
Document doc = null;
|
||||
if (dto.getDocumentId() != null) {
|
||||
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
||||
}
|
||||
|
||||
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
|
||||
.map(max -> max + POSITION_STEP)
|
||||
.orElse(POSITION_STEP);
|
||||
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(g)
|
||||
.position(nextPosition)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
JourneyItem saved = journeyItemRepository.save(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
// null = field absent from JSON → no-op
|
||||
Optional<String> noteField = dto.getNote();
|
||||
if (noteField == null) {
|
||||
return toView(item);
|
||||
}
|
||||
|
||||
String note = normalizeNote(noteField.orElse(null));
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
if (note == null && item.getDocumentId() == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Cannot clear note on an item that has no linked document");
|
||||
}
|
||||
|
||||
item.setNote(note);
|
||||
JourneyItem saved = journeyItemRepository.save(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID geschichteId, UUID itemId) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
journeyItemRepository.delete(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
|
||||
if (!geschichteQueryService.existsById(geschichteId)) {
|
||||
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Journey not found: " + geschichteId);
|
||||
}
|
||||
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
|
||||
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
|
||||
|
||||
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Duplicate item IDs in reorder request");
|
||||
}
|
||||
|
||||
if (!existingIds.equals(new HashSet<>(requestedIds))) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Requested item IDs do not match the journey's existing items");
|
||||
}
|
||||
|
||||
if (requestedIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
|
||||
Map<UUID, JourneyItem> itemMap = new HashMap<>();
|
||||
for (JourneyItem item : items) {
|
||||
itemMap.put(item.getId(), item);
|
||||
}
|
||||
|
||||
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
|
||||
for (int i = 0; i < requestedIds.size(); i++) {
|
||||
JourneyItem item = itemMap.get(requestedIds.get(i));
|
||||
item.setPosition((i + 1) * POSITION_STEP);
|
||||
toSave.add(item);
|
||||
}
|
||||
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
|
||||
|
||||
return reordered.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
public List<JourneyItemView> getItems(UUID geschichteId) {
|
||||
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
|
||||
.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
DocumentSummary toSummary(Document doc) {
|
||||
String senderName = buildSenderName(doc);
|
||||
Set<Person> receivers = doc.getReceivers();
|
||||
String receiverName = buildCanonicalReceiverName(receivers);
|
||||
|
||||
return new DocumentSummary(
|
||||
doc.getId(),
|
||||
doc.getTitle(),
|
||||
doc.getDocumentDate(),
|
||||
doc.getMetaDateEnd(),
|
||||
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
|
||||
senderName,
|
||||
receiverName,
|
||||
receivers != null ? receivers.size() : 0
|
||||
);
|
||||
}
|
||||
|
||||
JourneyItemView toView(JourneyItem item) {
|
||||
DocumentSummary docSummary = null;
|
||||
Document doc = item.getDocument();
|
||||
if (doc != null) {
|
||||
docSummary = toSummary(doc);
|
||||
}
|
||||
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
|
||||
}
|
||||
|
||||
private static String buildSenderName(Document doc) {
|
||||
Person sender = doc.getSender();
|
||||
if (sender != null) {
|
||||
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
|
||||
if (!name.isBlank()) return name;
|
||||
}
|
||||
String senderText = doc.getSenderText();
|
||||
return (senderText != null && !senderText.isBlank()) ? senderText : null;
|
||||
}
|
||||
|
||||
private static String buildCanonicalReceiverName(Set<Person> receivers) {
|
||||
if (receivers == null || receivers.isEmpty()) return null;
|
||||
return receivers.stream()
|
||||
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
|
||||
.map(p -> {
|
||||
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
|
||||
return name.isBlank() ? null : name;
|
||||
})
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static String normalizeNote(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
private static String sortKey(String s) {
|
||||
return s != null ? s : "";
|
||||
}
|
||||
|
||||
private AppUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
return userService.findByEmail(auth.getName());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Read-model response for a JourneyItem. Never the JPA entity (which has a
|
||||
* Geschichte back-reference that would leak / hit LazyInitializationException).
|
||||
*/
|
||||
public record JourneyItemView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
|
||||
DocumentSummary document,
|
||||
String note
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Result of {@link PersonService#resolveByName(String)}: candidate persons split by name-match
|
||||
* strength. {@code direct} = every query token is a whole-token match across the person's name
|
||||
* components (alias/maiden-name aware); {@code partial} = matched the substring fetch but is not
|
||||
* direct. The vocabulary is deliberately name-match strength ({@code direct}/{@code partial}), not
|
||||
* the search layer's resolved/ambiguous buckets — the caller maps these into its own outcome.
|
||||
*/
|
||||
public record NameMatches(List<Person> direct, List<Person> partial) {
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
"LOWER(CONCAT(COALESCE(p.firstName, ''),' ',p.lastName)) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(CONCAT(p.lastName, ' ', COALESCE(p.firstName, ''))) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(p.alias) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"LOWER(a.lastName) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
|
||||
"LOWER(a.firstName) LIKE LOWER(CONCAT('%', :query, '%')) " +
|
||||
"ORDER BY p.lastName ASC, p.firstName ASC")
|
||||
List<Person> searchByName(@Param("query") String query);
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
@@ -24,11 +30,20 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PersonService {
|
||||
|
||||
// Co-located with the fetch loop that owns them (issue #763). MAX_TOKENS caps the number of
|
||||
// unindexed leading-wildcard LIKE scans per name — a DoS control, not just perf. MAX_CANDIDATES
|
||||
// bounds each result bucket and is applied AFTER classification so a direct match that sorts
|
||||
// past position 10 among partials is never discarded.
|
||||
private static final int MAX_TOKENS = 8;
|
||||
private static final int MAX_CANDIDATES = 10;
|
||||
|
||||
private final PersonRepository personRepository;
|
||||
private final PersonNameAliasRepository aliasRepository;
|
||||
|
||||
@@ -103,6 +118,92 @@ public class PersonService {
|
||||
return personRepository.searchByName(fragment);
|
||||
}
|
||||
|
||||
// Name-match tokenizer (issue #763): lowercase, split on whitespace/hyphen/apostrophe,
|
||||
// drop empties. Applied symmetrically to the query and to every candidate name component so
|
||||
// that "Anna-Maria" and "Anna Maria" tokenize alike. Order-preserving for deterministic tests.
|
||||
static Set<String> tokenize(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return Set.of();
|
||||
}
|
||||
LinkedHashSet<String> tokens = new LinkedHashSet<>();
|
||||
for (String part : raw.toLowerCase(Locale.ROOT).split("[\\s\\-']+")) {
|
||||
if (!part.isEmpty()) {
|
||||
tokens.add(part);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an extracted person name into {@link NameMatches} by name-match strength.
|
||||
* Orchestrates tokenize → cap → fetch pool → classify → cap-after-classify. Read-only
|
||||
* transaction keeps the Hibernate session open so each candidate's lazy {@code nameAliases}
|
||||
* are reachable during classification (see ADR-022).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public NameMatches resolveByName(String name) {
|
||||
Set<String> queryTokens = capTokens(tokenize(name));
|
||||
if (queryTokens.isEmpty()) {
|
||||
log.debug("resolveByName outcome=no-match tokens=0");
|
||||
return new NameMatches(List.of(), List.of());
|
||||
}
|
||||
return classify(fetchPool(queryTokens), queryTokens);
|
||||
}
|
||||
|
||||
private Set<String> capTokens(Set<String> tokens) {
|
||||
return tokens.stream().limit(MAX_TOKENS).collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private List<Person> fetchPool(Set<String> queryTokens) {
|
||||
LinkedHashMap<UUID, Person> pool = new LinkedHashMap<>();
|
||||
for (String token : queryTokens) {
|
||||
for (Person candidate : findByDisplayNameContaining(token)) {
|
||||
pool.putIfAbsent(candidate.getId(), candidate);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(pool.values());
|
||||
}
|
||||
|
||||
private NameMatches classify(List<Person> pool, Set<String> queryTokens) {
|
||||
List<Person> direct = new ArrayList<>();
|
||||
List<Person> partial = new ArrayList<>();
|
||||
for (Person candidate : pool) {
|
||||
if (personTokens(candidate).containsAll(queryTokens)) {
|
||||
direct.add(candidate);
|
||||
} else {
|
||||
partial.add(candidate);
|
||||
}
|
||||
}
|
||||
List<Person> cappedDirect = cap(direct);
|
||||
List<Person> cappedPartial = cap(partial);
|
||||
log.debug("resolveByName outcome={} tokens={}", outcome(cappedDirect, cappedPartial), queryTokens.size());
|
||||
return new NameMatches(cappedDirect, cappedPartial);
|
||||
}
|
||||
|
||||
private static Set<String> personTokens(Person person) {
|
||||
Set<String> tokens = new LinkedHashSet<>();
|
||||
tokens.addAll(tokenize(person.getFirstName()));
|
||||
tokens.addAll(tokenize(person.getLastName()));
|
||||
tokens.addAll(tokenize(person.getAlias()));
|
||||
tokens.addAll(tokenize(person.getTitle()));
|
||||
for (PersonNameAlias alias : person.getNameAliases()) {
|
||||
tokens.addAll(tokenize(alias.getFirstName()));
|
||||
tokens.addAll(tokenize(alias.getLastName()));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static List<Person> cap(List<Person> people) {
|
||||
return people.size() > MAX_CANDIDATES ? people.subList(0, MAX_CANDIDATES) : people;
|
||||
}
|
||||
|
||||
private static String outcome(List<Person> direct, List<Person> partial) {
|
||||
if (direct.size() == 1) return "direct=1";
|
||||
if (direct.size() >= 2) return "direct>=2";
|
||||
if (!partial.isEmpty()) return "partial-only";
|
||||
return "no-match";
|
||||
}
|
||||
|
||||
public List<Person> findAllFamilyMembers() {
|
||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ Features: person CRUD, name alias management, person merge (deduplication), fami
|
||||
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
|
||||
| `findAll(String q)` | document, dashboard | List all persons |
|
||||
| `findByName(String firstName, String lastName)` | document | Filename-based **sender resolution** in `storeDocument`: exact-case match → single case-insensitive match → else **empty** (ambiguous names leave the sender unset; a null first name never matches). See ADR-033. |
|
||||
| `resolveByName(String name)` | search | NL-search name resolution returning `NameMatches` (direct vs partial). Token/word-boundary, alias-aware matching so a single direct match auto-selects even when looser substring hits coexist ("Clara Cram" vs "Clara Cramer"). See #763. |
|
||||
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally. Resolves exact-case → lowest-id case-insensitive sibling → create — never throws on case-colliding aliases. See ADR-033. |
|
||||
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
|
||||
| `findCorrespondents()` | document | Correspondent list for conversation filter |
|
||||
|
||||
@@ -1,22 +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)
|
||||
String rawQuery,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
boolean keywordsApplied
|
||||
) {
|
||||
}
|
||||
@@ -1,160 +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.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
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 MAX_CANDIDATES = 10;
|
||||
|
||||
private final OllamaClient ollamaClient;
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
|
||||
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();
|
||||
|
||||
NameResolution resolution = resolveNames(personNames);
|
||||
|
||||
if (!resolution.ambiguous().isEmpty()) {
|
||||
NlQueryInterpretation interpretation = new NlQueryInterpretation(
|
||||
List.of(), resolution.ambiguous(),
|
||||
ext.dateFrom(), ext.dateTo(),
|
||||
keywords, ext.rawQuery(), false);
|
||||
return new NlSearchResponse(DocumentSearchResult.of(List.of()), interpretation);
|
||||
}
|
||||
|
||||
List<PersonHint> resolved = resolution.resolved();
|
||||
List<String> noMatchFragments = resolution.noMatchFragments();
|
||||
List<String> extraFragments = resolution.extraFragments();
|
||||
|
||||
String text = buildText(keywords, noMatchFragments, extraFragments, ext.rawQuery());
|
||||
|
||||
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, ext.rawQuery(), false);
|
||||
return new NlSearchResponse(docs, interpretation);
|
||||
}
|
||||
|
||||
UUID sender = buildSender(resolved, ext.personRole());
|
||||
UUID receiver = buildReceiver(resolved, ext.personRole());
|
||||
|
||||
SearchFilters filters = new SearchFilters(
|
||||
text.isBlank() ? null : text,
|
||||
ext.dateFrom(), ext.dateTo(),
|
||||
sender, receiver,
|
||||
List.of(), null,
|
||||
null, TagOperator.AND, 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, ext.rawQuery(), keywordsApplied);
|
||||
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;
|
||||
}
|
||||
List<Person> candidates = personService.findByDisplayNameContaining(name);
|
||||
List<Person> capped = candidates.size() > MAX_CANDIDATES
|
||||
? candidates.subList(0, MAX_CANDIDATES)
|
||||
: candidates;
|
||||
|
||||
if (capped.isEmpty()) {
|
||||
noMatchFragments.add(name);
|
||||
} else if (capped.size() == 1) {
|
||||
Person p = capped.get(0);
|
||||
PersonHint hint = new PersonHint(p.getId(), p.getDisplayName());
|
||||
resolvedIndex++;
|
||||
if (resolvedIndex <= 2) {
|
||||
resolved.add(hint);
|
||||
} else {
|
||||
extraFragments.add(name);
|
||||
}
|
||||
} else {
|
||||
capped.forEach(p -> ambiguous.add(new PersonHint(p.getId(), p.getDisplayName())));
|
||||
}
|
||||
}
|
||||
|
||||
return new NameResolution(resolved, ambiguous, noMatchFragments, extraFragments);
|
||||
}
|
||||
|
||||
private String buildText(List<String> keywords, List<String> noMatchFragments,
|
||||
List<String> extraFragments, String rawQuery) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.addAll(keywords);
|
||||
parts.addAll(noMatchFragments);
|
||||
parts.addAll(extraFragments);
|
||||
String text = String.join(" ", parts).strip();
|
||||
if (text.isBlank() && 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
|
||||
) {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
public interface OllamaClient {
|
||||
OllamaExtraction parse(String query);
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package org.raddatz.familienarchiv.search;
|
||||
|
||||
public interface OllamaHealthClient {
|
||||
boolean isHealthy();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,10 @@ public class TagService {
|
||||
return enrichWithRelatives(matched);
|
||||
}
|
||||
|
||||
public List<Tag> findByNameContaining(String fragment) {
|
||||
return tagRepository.findByNameContainingIgnoreCase(fragment);
|
||||
}
|
||||
|
||||
public Tag getById(UUID id) {
|
||||
return tagRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.TAG_NOT_FOUND, "Tag not found: " + id));
|
||||
|
||||
@@ -12,6 +12,3 @@ springdoc:
|
||||
enabled: true
|
||||
path: /swagger-ui.html
|
||||
|
||||
app:
|
||||
ollama:
|
||||
base-url: http://localhost:11434
|
||||
|
||||
@@ -130,16 +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
|
||||
timeout-seconds: 30
|
||||
health-check-timeout-seconds: 2
|
||||
|
||||
nl-search:
|
||||
rate-limit:
|
||||
max-requests-per-minute: 5
|
||||
|
||||
ocr:
|
||||
sender-model:
|
||||
activation-threshold: 100
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -2,15 +2,13 @@ package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
@@ -21,22 +19,25 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.nullValue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(GeschichteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -47,11 +48,9 @@ class GeschichteControllerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean
|
||||
GeschichteService geschichteService;
|
||||
|
||||
@MockitoBean
|
||||
CustomUserDetailsService customUserDetailsService;
|
||||
@MockitoBean GeschichteService geschichteService;
|
||||
@MockitoBean JourneyItemService journeyItemService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@@ -64,8 +63,8 @@ class GeschichteControllerTest {
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_returns200_forReader() throws Exception {
|
||||
when(geschichteService.list(any(), any(), any(), anyInt()))
|
||||
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
|
||||
when(geschichteService.list(any(), any(), anyInt()))
|
||||
.thenReturn(List.of(summaryStub("Story A")));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -76,13 +75,13 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt()))
|
||||
when(geschichteService.list(any(), eq(List.of(personId)), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten").param("personId", personId.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt());
|
||||
verify(geschichteService).list(any(), eq(List.of(personId)), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,7 +89,7 @@ class GeschichteControllerTest {
|
||||
void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception {
|
||||
UUID a = UUID.randomUUID();
|
||||
UUID b = UUID.randomUUID();
|
||||
when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt()))
|
||||
when(geschichteService.list(any(), eq(List.of(a, b)), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten")
|
||||
@@ -98,7 +97,7 @@ class GeschichteControllerTest {
|
||||
.param("personId", b.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
|
||||
verify(geschichteService).list(any(), eq(List.of(a, b)), anyInt());
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||
@@ -107,7 +106,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns200_whenFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
|
||||
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
.andExpect(status().isOk())
|
||||
@@ -119,7 +118,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id))
|
||||
when(geschichteService.getView(id))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
@@ -208,8 +207,180 @@ class GeschichteControllerTest {
|
||||
verify(geschichteService).delete(id);
|
||||
}
|
||||
|
||||
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void appendItem_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Note\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value(itemId.toString()))
|
||||
.andExpect(jsonPath("$.position").value(10));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, "Updated"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Updated\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value("Updated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, null));
|
||||
|
||||
// Raw JSON — local objectMapper lacks JsonNullableModule
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\": null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value(nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns204_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(journeyItemService).delete(id, itemId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
|
||||
.when(journeyItemService).delete(id, itemId);
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[]}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void reorderItems_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
|
||||
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
|
||||
}
|
||||
|
||||
// ─── error mapping ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns409_on_position_conflict() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private JourneyItemView itemViewStub(UUID id, int position, String note) {
|
||||
return new JourneyItemView(id, position, null, note);
|
||||
}
|
||||
|
||||
private Geschichte published(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
@@ -220,7 +391,7 @@ class GeschichteControllerTest {
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -232,7 +403,27 @@ class GeschichteControllerTest {
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GeschichteView viewStub(UUID id, String title) {
|
||||
return new GeschichteView(id, title, "<p>x</p>",
|
||||
GeschichteStatus.PUBLISHED, GeschichteType.STORY,
|
||||
null, new HashSet<>(), List.of(),
|
||||
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
|
||||
}
|
||||
|
||||
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
|
||||
private GeschichteSummary summaryStub(String title) {
|
||||
return new GeschichteSummary() {
|
||||
public UUID getId() { return UUID.randomUUID(); }
|
||||
public String getTitle() { return title; }
|
||||
public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
|
||||
public GeschichteType getType() { return GeschichteType.STORY; }
|
||||
public AuthorSummary getAuthor() { return null; }
|
||||
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
|
||||
public String getBody() { return null; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
|
||||
*
|
||||
* <p>No {@code @Transactional} at class level — that would keep a session open and
|
||||
* mask LazyInitializationException caused by open-in-view: false. Each test seeds data
|
||||
* directly via repositories and relies on the service's own transaction boundaries.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class GeschichteHttpTest {
|
||||
|
||||
@LocalServerPort int port;
|
||||
@MockitoBean S3Client s3Client;
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired PasswordEncoder passwordEncoder;
|
||||
|
||||
private RestTemplate http;
|
||||
private String baseUrl;
|
||||
|
||||
private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
|
||||
private static final String WRITER_PASSWORD = "pass!Geschichte1";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
http = noThrowRestTemplate();
|
||||
baseUrl = "http://localhost:" + port;
|
||||
geschichteRepository.deleteAll();
|
||||
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||
appUserRepository.save(AppUser.builder()
|
||||
.email(WRITER_EMAIL)
|
||||
.password(passwordEncoder.encode(WRITER_PASSWORD))
|
||||
.build());
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void list_returns_200_and_empty_array_when_no_stories_exist() {
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).isEqualTo("[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_returns_200_and_does_not_500_when_stories_have_journey_items() {
|
||||
// Seed a JOURNEY directly — items are LAZY; without @Transactional(readOnly=true) +
|
||||
// Hibernate.initialize in getById() this would 500. list() uses a projection so it
|
||||
// must also never touch items.
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Reise durch die Briefe")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(journey)
|
||||
.position(1000)
|
||||
.note("Einleitung")
|
||||
.build();
|
||||
journey.getItems().add(item);
|
||||
geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).contains("Reise durch die Briefe");
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_returns_200_with_items_and_does_not_500_open_in_view_false() {
|
||||
// This test is the canonical guard against LazyInitializationException.
|
||||
// open-in-view: false means the Hibernate session is closed when Jackson serializes.
|
||||
// GeschichteService.getById() must initialize items inside its @Transactional boundary.
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Familiengeschichte")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
JourneyItem note = JourneyItem.builder()
|
||||
.geschichte(journey).position(1000).note("Prolog").build();
|
||||
JourneyItem note2 = JourneyItem.builder()
|
||||
.geschichte(journey).position(2000).note("Epilog").build();
|
||||
journey.getItems().add(note);
|
||||
journey.getItems().add(note2);
|
||||
Geschichte saved = geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("Familiengeschichte")
|
||||
.contains("Prolog")
|
||||
.contains("Epilog");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returns_404_for_unknown_id() {
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + UUID.randomUUID(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
assertThat(response.getBody()).contains("GESCHICHTE_NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() {
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte draft = Geschichte.builder()
|
||||
.title("Geheimer Entwurf")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.author(writer)
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
Geschichte saved = geschichteRepository.save(draft);
|
||||
|
||||
// Writer lacks explicit BLOG_WRITE permission in the app_users table,
|
||||
// so from the service's perspective they're a reader.
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private String loginAsWriter() {
|
||||
String xsrf = UUID.randomUUID().toString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
|
||||
headers.set("X-XSRF-TOKEN", xsrf);
|
||||
String body = "{\"email\":\"" + WRITER_EMAIL + "\",\"password\":\"" + WRITER_PASSWORD + "\"}";
|
||||
ResponseEntity<String> resp = http.postForEntity(
|
||||
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
|
||||
return extractFaSessionCookie(resp);
|
||||
}
|
||||
|
||||
private HttpHeaders sessionHeaders(String sessionId) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||
if (setCookieHeader == null) return "";
|
||||
return setCookieHeader.stream()
|
||||
.filter(c -> c.startsWith("fa_session="))
|
||||
.map(c -> c.split(";")[0].substring("fa_session=".length()))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private RestTemplate noThrowRestTemplate() {
|
||||
RestTemplate template = new RestTemplate();
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class GeschichteListProjectionTest {
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
|
||||
AppUser author;
|
||||
AppUser otherAuthor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
geschichteRepository.deleteAll();
|
||||
author = appUserRepository.save(AppUser.builder()
|
||||
.email("author@test").password("pw").build());
|
||||
otherAuthor = appUserRepository.save(AppUser.builder()
|
||||
.email("other@test").password("pw").build());
|
||||
}
|
||||
|
||||
// ─── findSummaries returns only the requested status ─────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() {
|
||||
geschichteRepository.save(published("Veröffentlicht", author));
|
||||
geschichteRepository.save(draft("Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
|
||||
geschichteRepository.save(draft("Nur Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── AuthorSummary nested projection ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_exposes_nested_author_firstName_lastName_email() {
|
||||
AppUser richAuthor = appUserRepository.save(AppUser.builder()
|
||||
.email("franz@raddatz.de").password("pw").build());
|
||||
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
|
||||
assertThat(a.getEmail()).isEqualTo("franz@raddatz.de");
|
||||
}
|
||||
|
||||
// ─── GeschichteType is exposed ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_exposes_type_field() {
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Eine Reise")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(author)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
geschichteRepository.save(journey);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
// ─── authorId filter (own-drafts gate) ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_authorId_returns_only_own_drafts() {
|
||||
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
|
||||
}
|
||||
|
||||
// ─── personCount = 0 → no person filter ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() {
|
||||
geschichteRepository.save(published("A", author));
|
||||
geschichteRepository.save(published("B", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
}
|
||||
|
||||
// ─── personCount > 0 AND-semantics ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_one_personId_returns_only_linked_stories() {
|
||||
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||
|
||||
Geschichte withFranz = published("Franz story", author);
|
||||
withFranz.getPersons().add(franz);
|
||||
geschichteRepository.save(withFranz);
|
||||
|
||||
Geschichte withAnna = published("Anna story", author);
|
||||
withAnna.getPersons().add(anna);
|
||||
geschichteRepository.save(withAnna);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_with_two_personIds_uses_AND_semantics() {
|
||||
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||
|
||||
Geschichte both = published("Both", author);
|
||||
both.getPersons().add(franz);
|
||||
both.getPersons().add(anna);
|
||||
geschichteRepository.save(both);
|
||||
|
||||
Geschichte onlyFranz = published("Only Franz", author);
|
||||
onlyFranz.getPersons().add(franz);
|
||||
geschichteRepository.save(onlyFranz);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Both");
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte published(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte draft(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.author(writer)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
|
||||
private List<UUID> sentinel() {
|
||||
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteView;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest {
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired GeschichteService geschichteService;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@@ -86,7 +90,7 @@ class GeschichteServiceIntegrationTest {
|
||||
|
||||
// Reader cannot see DRAFT in list
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
||||
assertThat(geschichteService.list(null, List.of(), 50)).isEmpty();
|
||||
|
||||
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
||||
UUID draftId = created.getId();
|
||||
@@ -102,11 +106,12 @@ class GeschichteServiceIntegrationTest {
|
||||
|
||||
// Reader can now see and fetch it
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(franz.getId()), 50)).hasSize(1);
|
||||
Geschichte fetched = geschichteService.getById(draftId);
|
||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
||||
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
|
||||
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
|
||||
|
||||
// Delete as writer; join rows go with it
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
@@ -136,26 +141,26 @@ class GeschichteServiceIntegrationTest {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
|
||||
// No filter → all three
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
assertThat(geschichteService.list(null, List.of(), 50))
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// Single filter (Anna) → all three
|
||||
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
assertThat(geschichteService.list(null, List.of(a.getId()), 50))
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), 50))
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactly(storyAB);
|
||||
|
||||
// AND: Bertha AND Carl → none (no story has both)
|
||||
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50))
|
||||
assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), 50))
|
||||
.isEmpty();
|
||||
|
||||
// AND: Anna AND Bertha AND Carl → none
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50))
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), 50))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest {
|
||||
geschichteService.create(dto);
|
||||
|
||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), 50);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@@ -7,26 +7,22 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -37,7 +33,9 @@ import java.util.stream.Collectors;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -45,17 +43,13 @@ import static org.mockito.Mockito.when;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GeschichteServiceTest {
|
||||
|
||||
@Mock
|
||||
GeschichteRepository geschichteRepository;
|
||||
@Mock
|
||||
PersonService personService;
|
||||
@Mock
|
||||
DocumentService documentService;
|
||||
@Mock
|
||||
UserService userService;
|
||||
@Mock GeschichteRepository geschichteRepository;
|
||||
@Mock PersonService personService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock UserService userService;
|
||||
@Mock JourneyItemService journeyItemService;
|
||||
|
||||
@InjectMocks
|
||||
GeschichteService geschichteService;
|
||||
@InjectMocks GeschichteService geschichteService;
|
||||
|
||||
AppUser writer;
|
||||
AppUser reader;
|
||||
@@ -96,7 +90,8 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result).isSameAs(draft);
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -108,7 +103,8 @@ class GeschichteServiceTest {
|
||||
|
||||
Geschichte result = geschichteService.getById(id);
|
||||
|
||||
assertThat(result).isSameAs(published);
|
||||
assertThat(result.getId()).isEqualTo(id);
|
||||
assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,79 +119,163 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ─── getView ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
|
||||
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
|
||||
|
||||
GeschichteView view = geschichteService.getView(id);
|
||||
|
||||
assertThat(view.id()).isEqualTo(id);
|
||||
assertThat(view.items()).containsExactly(item);
|
||||
verify(journeyItemService).getItems(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getView_throws_NOT_FOUND_when_id_unknown() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.getView(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_uses_firstName_lastName() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("author@test")
|
||||
.firstName("Hans").lastName("Raddatz").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("anon@test").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_email_is_not_in_author_view() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("secret@test")
|
||||
.firstName("Max").lastName("M").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
// AuthorView exposes only id + displayName — no email field at all
|
||||
assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
|
||||
assertThat(result.author().displayName()).doesNotContain("secret@test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_persons_are_mapped_to_PersonView() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID personId = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setPersons(new HashSet<>(List.of(
|
||||
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
|
||||
)));
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.persons()).hasSize(1);
|
||||
GeschichteView.PersonView pv = result.persons().iterator().next();
|
||||
assertThat(pv.id()).isEqualTo(personId);
|
||||
assertThat(pv.firstName()).isEqualTo("Franz");
|
||||
assertThat(pv.lastName()).isEqualTo("Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_items_are_passed_through() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
|
||||
geschichteService.list(null, List.of(), 50);
|
||||
|
||||
// Status pinning lives inside the Specification; we assert end-to-end behaviour
|
||||
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
|
||||
// through the spec-aware repository method.
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
|
||||
GeschichteSummary s1 = mock(GeschichteSummary.class);
|
||||
GeschichteSummary s2 = mock(GeschichteSummary.class);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
.thenReturn(List.of(s1, s2));
|
||||
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), 50);
|
||||
|
||||
assertThat(out).hasSize(2);
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(personId), null, 50);
|
||||
geschichteService.list(null, List.of(personId), 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID a = UUID.randomUUID();
|
||||
UUID b = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(a, b), null, 50);
|
||||
geschichteService.list(null, List.of(a, b), 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_filters_by_documentId() {
|
||||
void list_caps_limit_at_max_when_caller_passes_huge_value() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of());
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong()))
|
||||
.thenReturn(List.of(mock(GeschichteSummary.class)));
|
||||
|
||||
geschichteService.list(null, List.of(), documentId, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
|
||||
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), 9999);
|
||||
|
||||
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
||||
}
|
||||
@@ -282,25 +362,6 @@ class GeschichteServiceTest {
|
||||
assertThat(saved.getPersons()).containsExactly(person);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_resolves_documentIds_via_DocumentService() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).build();
|
||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Linked doc");
|
||||
dto.setDocumentIds(List.of(docId));
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getDocuments()).containsExactly(doc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_throws_BAD_REQUEST_when_title_blank() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
@@ -426,7 +487,7 @@ class GeschichteServiceTest {
|
||||
.body("<p>body</p>")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -438,7 +499,7 @@ class GeschichteServiceTest {
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.publishedAt(LocalDateTime.now().minusHours(1))
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
|
||||
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
|
||||
* rollback-only and cascades into TransactionSystemException on teardown.
|
||||
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class JourneyItemConstraintsTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
private UUID geschichteId;
|
||||
private UUID documentId;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbcTemplate.execute("DELETE FROM journey_items");
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Constraints-Test-Doc")
|
||||
.originalFilename("ct.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
documentId = doc.getId();
|
||||
Geschichte g = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Constraints-Test-Journey")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
geschichteId = g.getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_is_deferrable_initially_deferred() {
|
||||
Boolean condeferrable = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
Boolean condeferred = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
|
||||
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void position_check_rejects_nonpositive() {
|
||||
UUID itemId = UUID.randomUUID();
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
itemId, geschichteId, 0, "test"))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_rejects_duplicate_position_per_geschichte() {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class JourneyItemIntegrationTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@PersistenceContext
|
||||
EntityManager em;
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
|
||||
Geschichte journey;
|
||||
Document doc;
|
||||
AppUser writer;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
writer = appUserRepository.save(AppUser.builder()
|
||||
.email("journey-writer@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
doc = documentRepository.save(Document.builder()
|
||||
.title("Testbrief")
|
||||
.originalFilename("testbrief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
journey = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurity() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
var authorities = java.util.Arrays.stream(permissions)
|
||||
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||
.toList();
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
||||
}
|
||||
|
||||
// ─── @OrderBy ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void items_are_returned_in_position_order_regardless_of_insertion_order() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
|
||||
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
|
||||
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
|
||||
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).document(doc).build();
|
||||
managed.getItems().addAll(List.of(third, first, second));
|
||||
geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
List<Integer> positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();
|
||||
|
||||
assertThat(positions).containsExactly(1000, 2000, 3000);
|
||||
}
|
||||
|
||||
// ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_geschichte_cascade_deletes_all_journey_items() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
|
||||
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
|
||||
geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
UUID geschichteId = journey.getId();
|
||||
geschichteRepository.deleteById(geschichteId);
|
||||
em.flush();
|
||||
|
||||
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removing_item_from_items_list_triggers_orphan_removal() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
|
||||
geschichteRepository.save(reloaded);
|
||||
em.flush();
|
||||
|
||||
assertThat(journeyItemRepository.findById(itemId)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── GeschichteType round-trip ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void type_persists_as_JOURNEY_and_roundtrips() {
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void type_defaults_to_STORY_for_new_geschichten() {
|
||||
Geschichte story = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Erinnerung")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
|
||||
assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
|
||||
}
|
||||
|
||||
// ─── Note-only item (document_id IS NULL) ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void note_only_item_persists_without_document() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem note = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
|
||||
managed.getItems().add(note);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID noteId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isNull();
|
||||
assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
|
||||
}
|
||||
|
||||
// ─── Document-backed item exposes documentId ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void document_backed_item_exposes_document_uuid_via_getDocumentId() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).document(doc).build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
// ─── ON DELETE SET NULL ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_sets_item_document_to_null_not_delete_item() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).document(doc).note("still here").build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
|
||||
// Delete document — ON DELETE SET NULL fires at DB level
|
||||
documentRepository.deleteById(doc.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("still here");
|
||||
}
|
||||
|
||||
// ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
|
||||
|
||||
@Test
|
||||
void saving_item_with_neither_document_nor_note_violates_check_constraint() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem empty = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).build();
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
journeyItemRepository.save(empty);
|
||||
journeyItemRepository.flush();
|
||||
}).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
|
||||
|
||||
@Test
|
||||
void append_persists_item_at_position_10() {
|
||||
// Arrange: authenticate as a user with BLOG_WRITE
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("First stop");
|
||||
|
||||
// Act
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item exists in DB at position 10
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
assertThat(view.note()).isEqualTo("First stop");
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(1);
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_swaps_positions_atomically() {
|
||||
// Arrange: append two items (pos 10, pos 20)
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
|
||||
dto1.setNote("Item one");
|
||||
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
|
||||
|
||||
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
|
||||
dto2.setNote("Item two");
|
||||
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
|
||||
|
||||
assertThat(item1View.position()).isEqualTo(10);
|
||||
assertThat(item2View.position()).isEqualTo(20);
|
||||
|
||||
// Act: reorder with [item2, item1]
|
||||
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
|
||||
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
|
||||
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item2 is now at position 10, item1 is at position 20
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(2);
|
||||
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
|
||||
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JourneyItemServiceTest {
|
||||
|
||||
@Mock JourneyItemRepository journeyItemRepository;
|
||||
@Mock GeschichteQueryService geschichteQueryService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock UserService userService;
|
||||
|
||||
@InjectMocks JourneyItemService journeyItemService;
|
||||
|
||||
UUID geschichteId = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID actorId = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setupAuth() {
|
||||
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
|
||||
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
|
||||
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("test@test.de", null,
|
||||
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||
}
|
||||
|
||||
// ─── toSummary — name composition ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void toSummary_uses_linked_person_firstName_lastName() {
|
||||
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
|
||||
Document doc = makeDoc(docId, sender, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_falls_back_to_senderText_when_no_person() {
|
||||
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Familie Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(0);
|
||||
assertThat(summary.receiverName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
|
||||
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
|
||||
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
|
||||
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(2);
|
||||
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_SEASON_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.SEASON);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_APPROX_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.APPROX);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
|
||||
}
|
||||
|
||||
// ─── append ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void append_to_empty_journey_starts_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.save(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_after_reorder_continues_from_max_position() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
|
||||
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
|
||||
when(journeyItemRepository.save(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_neither_documentId_nor_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("documentId or note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_note_trims_to_empty_and_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote(" \n ");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_note_exceeds_5000_chars() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("x".repeat(5001));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_on_non_JOURNEY_type() {
|
||||
Geschichte story = Geschichte.builder()
|
||||
.id(geschichteId)
|
||||
.title("Story")
|
||||
.type(GeschichteType.STORY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_MISMATCH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY() {
|
||||
// Arrange: mock geschichteQueryService.findById() to return a STORY-type Geschichte
|
||||
UUID storyId = UUID.randomUUID();
|
||||
Geschichte story = Geschichte.builder()
|
||||
.id(storyId)
|
||||
.type(GeschichteType.STORY)
|
||||
.build();
|
||||
when(geschichteQueryService.findById(storyId)).thenReturn(Optional.of(story));
|
||||
|
||||
// Act + Assert: calling append throws GESCHICHTE_TYPE_MISMATCH
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
assertThatThrownBy(() -> journeyItemService.append(storyId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
// Verify: document service was never touched — type guard fired first
|
||||
verifyNoInteractions(documentService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns404_when_documentId_does_not_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(documentService.findSummaryByIdInternal(docId))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_100_items_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cap_is_COUNT_based_not_MAX_position_based() {
|
||||
// 99 rows with MAX(position)=2000 should still accept the 100th append
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
|
||||
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
|
||||
when(journeyItemRepository.save(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_audits_JOURNEY_ITEM_ADDED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.save(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
journeyItemService.append(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── updateNote ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateNote_absent_leaves_note_unchanged() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
// note is null by default — absent from JSON, no-op
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("Original note");
|
||||
verify(journeyItemRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_clears_note_when_document_is_present() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_string_sets_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
item.setNote(null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("New note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_returns400_when_item_has_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_whitespace_only_including_newlines_stored_as_null() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("\n \n"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_returns400_when_note_exceeds_5000_chars() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("x".repeat(5001)));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_auditsNoteUpdate() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_returns404_when_item_belongs_to_different_journey() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("text"));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
// ─── delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void delete_returns404_when_item_already_deleted() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_no_audit_when_item_not_found() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
journeyItemService.delete(geschichteId, itemId);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── reorder ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_unknownGeschichteId_throws404() {
|
||||
UUID unknownId = UUID.randomUUID();
|
||||
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemIds_contain_duplicates() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id1)); // duplicate
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemId_belongs_to_different_journey() {
|
||||
UUID foreignId = UUID.randomUUID();
|
||||
UUID localId = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(foreignId));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_ids_have_extra_items() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns200_when_empty_on_empty_journey() {
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_empty_on_nonempty_journey() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns_items_in_new_order_starting_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
|
||||
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2)); // want id1 first
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(2);
|
||||
assertThat(views.get(0).id()).isEqualTo(id1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
assertThat(views.get(1).id()).isEqualTo(id2);
|
||||
assertThat(views.get(1).position()).isEqualTo(20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_identical_order_returns200() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_of_grandfathered_over_cap_journey_succeeds() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
|
||||
List<UUID> ids = new java.util.ArrayList<>();
|
||||
List<JourneyItem> items = new java.util.ArrayList<>();
|
||||
for (int i = 1; i <= 130; i++) {
|
||||
UUID id = UUID.randomUUID();
|
||||
ids.add(id);
|
||||
items.add(savedItem(id, journey, i * 10, null, "item " + i));
|
||||
}
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(ids);
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(130);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte journey(UUID id) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title("Test Journey")
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
return JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(null) // no document entity to avoid LAZY issues in unit tests
|
||||
.note(note)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
return item;
|
||||
}
|
||||
|
||||
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Test Doc")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.senderText(senderText)
|
||||
.receiverText(receiverText)
|
||||
.sender(sender)
|
||||
.build();
|
||||
doc.setReceivers(new HashSet<>(receivers));
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -428,6 +428,67 @@ class PersonRepositoryTest {
|
||||
assertThat(results).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_findsByAliasFirstName() {
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Wilhelmina");
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getLastName()).isEqualTo("Cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchByName_ordersByLastNameThenFirstName() {
|
||||
personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
personRepository.save(Person.builder().firstName("Anna").lastName("Cram").build());
|
||||
personRepository.save(Person.builder().firstName("Bernd").lastName("Cram").build());
|
||||
|
||||
List<Person> results = personRepository.searchByName("Cram");
|
||||
|
||||
assertThat(results).extracting(Person::getFirstName)
|
||||
.containsExactly("Anna", "Bernd", "Clara");
|
||||
}
|
||||
|
||||
// ─── resolveByName fetch→classify, end-to-end on real Postgres (#763 review) ───
|
||||
// The classifier unit tests in PersonServiceTest stub searchByName, so they never prove the
|
||||
// fetch query actually finds an alias-only match and feeds it into classification. These walk
|
||||
// the whole searchByName → resolveByName path over the real Postgres slice, closing AC#4/#5.
|
||||
|
||||
@Test
|
||||
void resolveByName_maidenAlias_classifiesAsDirect_endToEnd() {
|
||||
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).lastName("Cram").type(PersonNameAliasType.MAIDEN_NAME).sortOrder(0).build());
|
||||
// Detach so resolveByName re-fetches with its lazy nameAliases loaded from the DB —
|
||||
// the fresh-session behaviour the @Transactional(readOnly=true) path has in production.
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
NameMatches matches = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_aliasFirstName_classifiesAsDirect_endToEnd() {
|
||||
PersonService personService = new PersonService(personRepository, aliasRepository);
|
||||
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||
aliasRepository.save(PersonNameAlias.builder()
|
||||
.person(clara).firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).sortOrder(0).build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
NameMatches matches = personService.resolveByName("Wilhelmina");
|
||||
|
||||
assertThat(matches.direct()).extracting(Person::getId).containsExactly(clara.getId());
|
||||
}
|
||||
|
||||
// ─── searchWithDocumentCount with aliases ────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -909,4 +909,154 @@ class PersonServiceTest {
|
||||
assertThat(result).containsExactly(walter);
|
||||
verify(personRepository).searchByName("Walter");
|
||||
}
|
||||
|
||||
// ─── tokenize (name-match contract) ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void tokenize_hyphenatedName_splitsOnHyphen() {
|
||||
assertThat(PersonService.tokenize("Anna-Maria")).containsExactly("anna", "maria");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_apostropheName_splitsOnApostrophe() {
|
||||
assertThat(PersonService.tokenize("D'Angelo")).containsExactly("d", "angelo");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_umlautName_lowercasesToSingleToken() {
|
||||
assertThat(PersonService.tokenize("Müller")).containsExactly("müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_doubleSpace_dropsEmptyTokens() {
|
||||
assertThat(PersonService.tokenize("Clara Cram")).containsExactly("clara", "cram");
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_allWhitespace_returnsEmpty() {
|
||||
assertThat(PersonService.tokenize(" ")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenize_null_returnsEmpty() {
|
||||
assertThat(PersonService.tokenize(null)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── resolveByName (direct / partial classification) ──────────────────────
|
||||
|
||||
@Test
|
||||
void resolveByName_singleDirectMatch_classifiesAsDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_maidenAliasToken_classifiesAsDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Müller")
|
||||
.nameAliases(List.of(PersonNameAlias.builder().lastName("Cram")
|
||||
.type(PersonNameAliasType.MAIDEN_NAME).build()))
|
||||
.build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_aliasFirstNameToken_isFetchedAndClassified() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram")
|
||||
.nameAliases(List.of(PersonNameAlias.builder().firstName("Wilhelmina").lastName("de Gruyter")
|
||||
.type(PersonNameAliasType.BIRTH).build()))
|
||||
.build();
|
||||
when(personRepository.searchByName("wilhelmina")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Wilhelmina");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_middleName_stillDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara Maria").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_reorderedTokens_stillDirect() {
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Cram Clara");
|
||||
|
||||
assertThat(result.direct()).containsExactly(clara);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_cramVsCramer_classifiesAsPartial() {
|
||||
Person cramer = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(cramer));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(cramer));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.partial()).containsExactly(cramer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_emptyAfterTokenizing_returnsNoCandidates() {
|
||||
NameMatches result = personService.resolveByName(" - ");
|
||||
|
||||
assertThat(result.direct()).isEmpty();
|
||||
verify(personRepository, never()).searchByName(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_directSortsBeyondCap_stillReturnedAsDirect() {
|
||||
List<Person> pool = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
pool.add(Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cramer").build());
|
||||
}
|
||||
Person direct = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
pool.add(direct);
|
||||
when(personRepository.searchByName("clara")).thenReturn(pool);
|
||||
when(personRepository.searchByName("cram")).thenReturn(pool);
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).containsExactly(direct);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_over8Tokens_issuesAtMost8Fetches() {
|
||||
personService.resolveByName("a b c d e f g h i j");
|
||||
|
||||
verify(personRepository, org.mockito.Mockito.atMost(8)).searchByName(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveByName_samePersonFromTwoTokens_appearsOnce() {
|
||||
// Both token fetches return the same person id — fetchPool's putIfAbsent must dedup so the
|
||||
// candidate is classified once, not twice.
|
||||
Person clara = Person.builder().id(UUID.randomUUID()).firstName("Clara").lastName("Cram").build();
|
||||
when(personRepository.searchByName("clara")).thenReturn(List.of(clara));
|
||||
when(personRepository.searchByName("cram")).thenReturn(List.of(clara));
|
||||
|
||||
NameMatches result = personService.resolveByName("Clara Cram");
|
||||
|
||||
assertThat(result.direct()).hasSize(1);
|
||||
assertThat(result.partial()).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,440 +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.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.tag.TagOperator;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
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;
|
||||
|
||||
NlQueryParserService service;
|
||||
|
||||
static final Pageable PAGE = PageRequest.of(0, 20);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
service = new NlQueryParserService(ollamaClient, personService, documentService);
|
||||
when(documentService.searchDocuments(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(List.of()));
|
||||
when(documentService.searchDocumentsByPersonId(any(), any(), any(), any()))
|
||||
.thenReturn(DocumentSearchResult.of(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 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.findByDisplayNameContaining("Walter")).thenReturn(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.findByDisplayNameContaining("Walter")).thenReturn(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.findByDisplayNameContaining("Emma")).thenReturn(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.findByDisplayNameContaining("Karl")).thenReturn(List.of());
|
||||
|
||||
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.findByDisplayNameContaining("Walter")).thenReturn(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.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(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.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(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.findByDisplayNameContaining("Karl")).thenReturn(List.of());
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(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.findByDisplayNameContaining("Walter")).thenReturn(List.of(walter));
|
||||
when(personService.findByDisplayNameContaining("Emma")).thenReturn(List.of(emma));
|
||||
when(personService.findByDisplayNameContaining("Heinrich")).thenReturn(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.findByDisplayNameContaining("Walter")).thenReturn(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()).findByDisplayNameContaining(anyString());
|
||||
}
|
||||
|
||||
// --- 20. Max 10 candidates cap: 11 persons returned → only first 10 in ambiguousPersons ---
|
||||
|
||||
@Test
|
||||
void search_elevenCandidates_capsAtTen() {
|
||||
List<Person> eleven = new ArrayList<>();
|
||||
for (int i = 0; i < 11; i++) {
|
||||
eleven.add(person(UUID.randomUUID(), "Walter", "Person" + i));
|
||||
}
|
||||
when(ollamaClient.parse(anyString()))
|
||||
.thenReturn(extraction(List.of("Walter"), "sender", null, null, List.of()));
|
||||
when(personService.findByDisplayNameContaining("Walter")).thenReturn(eleven);
|
||||
|
||||
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.findByDisplayNameContaining("Emma")).thenReturn(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();
|
||||
}
|
||||
}
|
||||
@@ -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"), "Briefe von Walter im Krieg", true);
|
||||
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(), "Briefe von Walter", 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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -666,4 +666,17 @@ class TagServiceTest {
|
||||
// verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors
|
||||
verify(tagRepository, atLeastOnce()).findAllById(any());
|
||||
}
|
||||
|
||||
// ─── findByNameContaining ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findByNameContaining_delegatesToRepository() {
|
||||
Tag krieg = Tag.builder().id(UUID.randomUUID()).name("Krieg").build();
|
||||
when(tagRepository.findByNameContainingIgnoreCase("krieg")).thenReturn(List.of(krieg));
|
||||
|
||||
List<Tag> result = tagService.findByNameContaining("krieg");
|
||||
|
||||
assertThat(result).containsExactly(krieg);
|
||||
verify(tagRepository).findByNameContainingIgnoreCase("krieg");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,65 +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
|
||||
command: >
|
||||
sh -c "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"
|
||||
|
||||
# --- 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}"
|
||||
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", "curl", "-f", "http://localhost:11434/api/tags"]
|
||||
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:
|
||||
@@ -243,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)
|
||||
@@ -308,4 +247,3 @@ volumes:
|
||||
frontend_node_modules:
|
||||
ocr_models:
|
||||
ocr_cache:
|
||||
ollama_models:
|
||||
|
||||
@@ -38,7 +38,7 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
|
||||
|
||||
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
|
||||
|
||||
**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body).
|
||||
**`geschichte`** — family stories and Lesereisen. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle) and `JourneyItem` (ordered stops in a JOURNEY-type Geschichte). Two subtypes: `STORY` (prose) and `JOURNEY` (curated document sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL).
|
||||
|
||||
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
@@ -147,16 +145,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 +265,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 60–90 minutes; at 100 Mbps approximately 6–10 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 60–90 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 +561,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` | `30` | Read timeout for inference calls |
|
||||
| `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 (~4–8 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**
|
||||
|
||||
@@ -149,7 +149,17 @@ _See also [Chronik](#chronik-internal)._
|
||||
|
||||
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
|
||||
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
|
||||
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a single stop in a *Lesereise* (`Geschichte` with `type=JOURNEY`). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per journey). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`).
|
||||
|
||||
**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List<JourneyItemView>` loaded via a separate query rather than a lazy collection.
|
||||
|
||||
**JourneyItemView** (`JourneyItemView`) `[internal]` — lean view record for a single `JourneyItem` surface, containing `id`, `position`, an optional `DocumentSummary`, and an optional `note`.
|
||||
|
||||
**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info.
|
||||
|
||||
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
|
||||
|
||||
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
|
||||
|
||||
@@ -165,15 +175,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), and `keywordsApplied` (whether keyword FTS was used in the search).
|
||||
|
||||
**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").
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,65 +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 1899–1950 — 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 2–15 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:** `RestClientOllamaClient.isHealthy()` is called inline before each inference request (calls `GET /api/tags` on a 2-second connect-timeout client). If Ollama is absent or times out, `NlQueryParserService` throws `DomainException` with `SMART_SEARCH_UNAVAILABLE` (HTTP 503). The regular structured search (`GET /api/documents/search`) is unaffected — it never calls Ollama.
|
||||
|
||||
**Expected inference latency:** 2–15 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, 10–30 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 (20–60 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.
|
||||
|
||||
## 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 2–15 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`.
|
||||
@@ -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.
|
||||
53
docs/adr/034-remove-nl-search.md
Normal file
53
docs/adr/034-remove-nl-search.md
Normal 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 10–30 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.
|
||||
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# ADR-035 — `Optional<String>` for three-way PATCH semantics
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-08
|
||||
**Issue:** #751 (JourneyItem CRUD API)
|
||||
|
||||
## Context
|
||||
|
||||
The `PATCH /api/geschichten/{id}/items/{itemId}` endpoint must distinguish three cases for the `note` field:
|
||||
|
||||
| JSON body | Intended meaning |
|
||||
|-------------------|-----------------------|
|
||||
| `{"note": "text"}`| Set note to "text" |
|
||||
| `{"note": null}` | Clear the note |
|
||||
| `{}` (absent) | Leave note unchanged |
|
||||
|
||||
The standard library for this on Jackson 2.x is `jackson-databind-nullable` (`JsonNullable<T>` from `org.openapitools`). However, that library targets `com.fasterxml.jackson.*` (Jackson 2.x) and is incompatible with Spring Boot 4.0 / Spring Framework 7, which uses `tools.jackson.*` (Jackson 3.x). The module fails to register and throws at startup.
|
||||
|
||||
## Decision
|
||||
|
||||
Use `Optional<String>` with Java's default field initializer (`= null`) to encode the three states:
|
||||
|
||||
```java
|
||||
@Data
|
||||
public class JourneyItemUpdateDTO {
|
||||
private Optional<String> note = null; // Java default — absent = no-op
|
||||
}
|
||||
```
|
||||
|
||||
| Java value | JSON wire | Semantics |
|
||||
|--------------------|-------------------|---------------|
|
||||
| `null` (default) | field absent | no-op |
|
||||
| `Optional.empty()` | `{"note": null}` | clear |
|
||||
| `Optional.of("x")` | `{"note": "x"}` | set |
|
||||
|
||||
Jackson 3.x natively maps a JSON `null` to `Optional.empty()` and leaves absent fields at their Java default. No custom module is needed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No external dependency for PATCH semantics — simpler pom.xml.
|
||||
- The DTO field type is `Optional<String>`, not `String` — service code must null-check the field first (`if (noteField == null) return;`) and then call `.orElse(null)` to unwrap.
|
||||
- This pattern applies to any future PATCH DTO that needs three-way semantics on a nullable field.
|
||||
- `jackson-databind-nullable` is removed from `pom.xml`; `JacksonConfig.java` is kept as a placeholder for future custom modules.
|
||||
@@ -12,16 +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.")
|
||||
Container(ollama, "Ollama", "Ollama / port 11434", "Local LLM inference server. Hosts qwen2.5:7b-instruct-q4_K_M for natural-language query parsing (NL Search). CPU-only; GPU not required.")
|
||||
}
|
||||
|
||||
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.")
|
||||
@@ -44,13 +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(backend, ollama, "NL search inference requests", "HTTP / REST / JSON")
|
||||
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")
|
||||
|
||||
@@ -16,8 +16,10 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
|
||||
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
|
||||
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories (STORY) and reading journeys (JOURNEY). Returns GeschichteSummary projections for list; full Geschichte with JourneyItems for detail. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Supports two subtypes: STORY (prose) and JOURNEY (ordered JourneyItem sequence). Sanitizes HTML body with an allowlist policy.")
|
||||
Component(geschQuerySvc, "GeschichteQueryService", "Spring Service", "Read-only facade over GeschichteRepository. Exposes existsById() and findById() to prevent JourneyItemService from crossing domain boundaries.")
|
||||
Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap). Enforces JOURNEY-type guard on append.")
|
||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||
}
|
||||
|
||||
@@ -38,6 +40,10 @@ Rel(notifCtrl, notifSvc, "Delegates to")
|
||||
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
|
||||
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
|
||||
Rel(geschCtrl, geschSvc, "Delegates to")
|
||||
Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD")
|
||||
Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type")
|
||||
Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
|
||||
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")
|
||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||
|
||||
@@ -1,33 +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 each person name via PersonService.findByDisplayNameContaining(), (4) applies multi-name / personRole heuristics, (5) 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).")
|
||||
|
||||
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, 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")
|
||||
|
||||
@enduml
|
||||
@@ -10,6 +10,11 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(homePage, "/ (Home / Search)", "SvelteKit Route", "Loader: parses URL params (q, from, to, senderId, receiverId, tags), fetches /api/documents/search and /api/persons. Renders search form with full-text, date range, sender/receiver typeahead, and tag filters.")
|
||||
Component(docsListPageTs, "/documents/+page.ts", "SvelteKit Client Loader", "Client-side load gated by matchMedia('(min-width: 1024px)') and ?view query. Fetches /api/documents/density only on desktop (Tailwind lg breakpoint) and outside calendar view; degrades to empty buckets on network failure.")
|
||||
Component(timelineFilter, "TimelineDensityFilter.svelte", "Svelte Component", "Per-month density bars above the document list. Click selects a single month, emits onchange({from, to}) using YYYY-MM-DD boundaries. Hidden on mobile and tablet (below lg, 1024px) and in calendar view.")
|
||||
Component(searchFilterBar, "SearchFilterBar.svelte", "Svelte Component", "Search/filter card on /documents. Hosts the keyword input, sort, advanced filters, and the smart-mode toggle. In smart mode submits the NL query on Enter via onSmartSearch instead of the live keyword search.")
|
||||
Component(smartToggle, "search/SmartModeToggle.svelte", "Svelte Component", "Toggle pill (KI/Text) inside the search input. aria-pressed; switches between keyword and NL (smart) search modes.")
|
||||
Component(chipRow, "search/InterpretationChipRow.svelte", "Svelte Component", "Renders NL interpretation chips (Absender / directional / Zeitraum / Stichwort). Removing a chip emits onRemoveChip; the page re-runs a keyword GET with the remaining params.")
|
||||
Component(smartStatus, "search/SmartSearchStatus.svelte", "Svelte Component", "Full-area panels for NL search: loading (role=status), 503 SMART_SEARCH_UNAVAILABLE (with keyword fallback), 429 SMART_SEARCH_RATE_LIMITED.")
|
||||
Component(disambig, "search/DisambiguationPicker.svelte", "Svelte Component", "Accessible single-select disclosure for ambiguous person names; selecting a candidate re-runs the search via GET.")
|
||||
Component(docDetail, "/documents/[id]", "SvelteKit Route", "Loader: GET /api/documents/{id}. Page: metadata panel, inline file viewer, transcription editor, annotation layer, and comment thread.")
|
||||
Component(docEdit, "/documents/[id]/edit", "SvelteKit Route", "Edit form with PersonTypeahead, TagInput, date/location fields. Form action: PUT /api/documents/{id}.")
|
||||
Component(docNew, "/documents/new", "SvelteKit Route", "Upload form for a new document. Loader: GET /api/persons. Form action: POST /api/documents with multipart file.")
|
||||
@@ -25,6 +30,12 @@ Rel(user, homePage, "Searches and browses", "HTTPS / Browser")
|
||||
Rel(homePage, backend, "GET /api/documents/search, GET /api/persons", "HTTP / JSON")
|
||||
Rel(docsListPageTs, backend, "GET /api/documents/density (desktop only, ≥1024px)", "HTTP / JSON")
|
||||
Rel(homePage, timelineFilter, "Mounts above the result list")
|
||||
Rel(homePage, searchFilterBar, "Mounts the search/filter card")
|
||||
Rel(searchFilterBar, smartToggle, "Embeds the smart-mode toggle in the input")
|
||||
Rel(homePage, backend, "POST /api/search/nl (smart mode)", "HTTP / JSON")
|
||||
Rel(homePage, smartStatus, "Renders loading / 503 / 429 panels")
|
||||
Rel(homePage, chipRow, "Renders interpretation chips; handles chip removal")
|
||||
Rel(homePage, disambig, "Renders the picker when names are ambiguous")
|
||||
Rel(docsListPageTs, timelineFilter, "Provides density / minDate / maxDate props")
|
||||
Rel(docDetail, backend, "GET /api/documents/{id}, GET /api/documents/{id}/file", "HTTP / JSON + Binary")
|
||||
Rel(docEdit, backend, "PUT /api/documents/{id}", "HTTP / Multipart")
|
||||
|
||||
@@ -11,8 +11,8 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
||||
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (rich text editor, person linking, POST /api/geschichten) or JOURNEY placeholder (editor deferred to #753). Edit: PUT /api/geschichten/{id}. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
@@ -24,8 +24,8 @@ Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearc
|
||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@startuml db-orm
|
||||
' Schema source: Flyway V1–V69 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V69 (2026-05-27)
|
||||
' Schema source: Flyway V1–V72 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V72 (2026-06-08)
|
||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||
|
||||
hide circle
|
||||
@@ -359,6 +359,7 @@ package "Supporting" {
|
||||
title : VARCHAR(255) NOT NULL
|
||||
body : TEXT
|
||||
status : VARCHAR(32) NOT NULL
|
||||
type : VARCHAR(32) NOT NULL
|
||||
author_id : UUID <<FK>>
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
updated_at : TIMESTAMP NOT NULL
|
||||
@@ -370,9 +371,15 @@ package "Supporting" {
|
||||
person_id : UUID <<FK>>
|
||||
}
|
||||
|
||||
entity geschichten_documents {
|
||||
entity journey_items {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
geschichte_id : UUID <<FK>>
|
||||
document_id : UUID <<FK>>
|
||||
position : INTEGER NOT NULL CHECK (position > 0)
|
||||
note : TEXT
|
||||
==
|
||||
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +443,7 @@ audit_log }o--o| documents : document_id
|
||||
geschichten }o--o| app_users : author_id
|
||||
geschichten_persons }o--|| geschichten : geschichte_id
|
||||
geschichten_persons }o--|| persons : person_id
|
||||
geschichten_documents }o--|| geschichten : geschichte_id
|
||||
geschichten_documents }o--|| documents : document_id
|
||||
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
|
||||
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -66,7 +66,7 @@ package "Supporting" {
|
||||
entity audit_log
|
||||
entity geschichten
|
||||
entity geschichten_persons
|
||||
entity geschichten_documents
|
||||
entity journey_items
|
||||
}
|
||||
|
||||
' Auth relationships
|
||||
@@ -129,7 +129,7 @@ audit_log }o--o| documents : document_id
|
||||
geschichten }o--o| app_users : author_id
|
||||
geschichten_persons }o--|| geschichten : geschichte_id
|
||||
geschichten_persons }o--|| persons : person_id
|
||||
geschichten_documents }o--|| geschichten : geschichte_id
|
||||
geschichten_documents }o--|| documents : document_id
|
||||
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
|
||||
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
|
||||
|
||||
@enduml
|
||||
|
||||
808
docs/specs/lesereisen-editor-spec.html
Normal file
808
docs/specs/lesereisen-editor-spec.html
Normal file
@@ -0,0 +1,808 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Lesereisen — Journey-Editor · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
|
||||
.jh-o .jn{color:var(--orange);}
|
||||
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.fa-link.active{color:var(--mint);}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
|
||||
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
|
||||
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
|
||||
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
|
||||
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
|
||||
|
||||
/* ── impl-ref table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}
|
||||
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
|
||||
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
|
||||
.at tr:last-child td{border-bottom:none;}
|
||||
.at td:first-child{color:#7A7A72;}
|
||||
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
|
||||
.at td:nth-child(3){color:#5A5A55;}
|
||||
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
/* ── LLM guide ── */
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
|
||||
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
/* ── Editor chrome (shared with writer spec) ── */
|
||||
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
|
||||
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
|
||||
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
|
||||
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
|
||||
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
|
||||
.ed-status-pub{background:var(--green-tint);color:var(--green-dark);border:1px solid #A0D8A8;}
|
||||
.ed-delete-link{font-size:8px;font-weight:600;color:#DC4C3E;margin-left:8px;}
|
||||
.ed-split{display:flex;flex:1;overflow:hidden;}
|
||||
.ed-sidebar{width:210px;flex-shrink:0;border-left:1px solid #e4e2d7;background:#fff;display:flex;flex-direction:column;overflow-y:auto;}
|
||||
.ed-sb-section{padding:12px 12px 10px;}
|
||||
.ed-sb-title{font-size:8px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
|
||||
.ed-sb-divider{height:1px;background:#e4e2d7;}
|
||||
.ed-search-row{display:flex;align-items:center;gap:6px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:4px 8px;margin-bottom:6px;}
|
||||
.ed-search-input{font-size:9px;color:var(--color-text-muted);}
|
||||
.ed-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 7px;background:var(--sand);border:1px solid var(--color-border);border-radius:12px;font-size:8px;font-weight:500;color:var(--color-text);margin:0 4px 4px 0;}
|
||||
.ed-chip-x{color:var(--color-text-muted);font-size:9px;cursor:pointer;margin-left:2px;}
|
||||
.ed-hint{font-size:8px;color:var(--color-text-muted);line-height:1.5;margin-top:4px;}
|
||||
.ed-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:9px 14px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;gap:10px;}
|
||||
.ed-savebar-hint{font-size:8px;color:var(--color-text-muted);}
|
||||
.ed-savebar-actions{display:flex;align-items:center;gap:7px;}
|
||||
.ed-btn-ghost{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);border:1px solid var(--color-border);color:var(--color-text);background:#fff;cursor:pointer;white-space:nowrap;}
|
||||
.ed-btn-ghost.retract{color:#B46820;border-color:#E8D5B0;}
|
||||
.ed-btn-primary{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);background:var(--navy);color:#fff;border:none;cursor:pointer;white-space:nowrap;}
|
||||
|
||||
/* ── Journey Editor main area ── */
|
||||
.je-main{flex:1;display:flex;flex-direction:column;padding:14px 16px;overflow-y:auto;gap:8px;background:var(--color-page);}
|
||||
.je-title-input{font-family:var(--font-display);font-size:15px;font-weight:400;color:var(--color-text);border:none;border-bottom:1px solid var(--color-border);padding:4px 0 6px;width:100%;outline:none;background:transparent;letter-spacing:-.01em;}
|
||||
.je-title-input.placeholder{color:var(--color-text-muted);font-style:italic;}
|
||||
.je-sep{height:1px;background:var(--color-border);margin:2px 0;}
|
||||
.je-intro-area{font-family:Georgia,serif;font-size:9px;line-height:1.7;color:var(--color-text-muted);font-style:italic;border:none;padding:5px 0;width:100%;outline:none;background:transparent;min-height:36px;resize:none;}
|
||||
.je-intro-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:2px;}
|
||||
.je-list-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:5px;margin-top:4px;}
|
||||
|
||||
/* ── Item rows ── */
|
||||
.je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:4px;margin-bottom:5px;overflow:hidden;}
|
||||
.je-drag{width:16px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;cursor:grab;flex-shrink:0;}
|
||||
.je-drag-dots{display:flex;flex-direction:column;gap:2px;}
|
||||
.je-drag-dot{width:3px;height:3px;border-radius:50%;background:#C4C3BC;}
|
||||
.je-num{width:20px;display:flex;align-items:flex-start;justify-content:center;padding-top:8px;font-size:8px;font-weight:700;color:#9B9A93;flex-shrink:0;}
|
||||
.je-body{flex:1;padding:7px 8px 7px 4px;}
|
||||
.je-doc-title{font-size:9px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:2px;}
|
||||
.je-doc-meta{font-size:7.5px;color:var(--color-text-muted);margin-bottom:5px;}
|
||||
.je-note-area{width:100%;min-height:32px;font-family:Georgia,serif;font-size:8px;line-height:1.55;color:var(--color-text);font-style:italic;border:1px solid var(--color-border);border-radius:3px;background:var(--color-surface);padding:4px 6px;resize:none;outline:none;}
|
||||
.je-note-add{font-size:7.5px;font-weight:600;color:var(--blue);cursor:pointer;display:inline-flex;align-items:center;gap:2px;}
|
||||
.je-remove{width:24px;display:flex;align-items:flex-start;justify-content:center;padding-top:7px;flex-shrink:0;}
|
||||
.je-remove-x{font-size:11px;color:#C4C3BC;cursor:pointer;line-height:1;font-weight:300;}
|
||||
.je-interlude-bg{background:var(--orange-tint);border-color:#F0C99A;}
|
||||
.je-interlude-icon{font-size:8px;color:var(--orange);margin-bottom:2px;}
|
||||
.je-interlude-area{width:100%;min-height:36px;font-family:Georgia,serif;font-size:8px;line-height:1.6;color:var(--color-text);font-style:italic;border:1px solid #F0C99A;border-radius:3px;background:rgba(255,255,255,.6);padding:4px 6px;resize:none;outline:none;}
|
||||
.je-empty{padding:16px;text-align:center;border:1px dashed var(--color-border);border-radius:4px;background:var(--color-surface);}
|
||||
.je-empty-text{font-family:Georgia,serif;font-size:8px;color:var(--color-text-muted);font-style:italic;}
|
||||
|
||||
/* ── Add bar ── */
|
||||
.je-add-bar{display:flex;gap:7px;padding:6px 0 4px;}
|
||||
.je-add-btn{font-size:8px;font-weight:600;padding:5px 10px;border-radius:3px;border:1px dashed var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:3px;}
|
||||
.je-add-btn:hover{border-color:var(--navy);color:var(--navy);}
|
||||
|
||||
/* ── Inline note editing state (highlight) ── */
|
||||
.je-note-editing{border-color:var(--navy);background:#fff;}
|
||||
|
||||
/* ── Mobile journey editor ── */
|
||||
.mob-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 10px;gap:6px;height:34px;flex-shrink:0;}
|
||||
.mob-back{font-size:8px;color:var(--color-text-muted);}
|
||||
.mob-label{font-family:var(--font-sans);font-size:9px;font-weight:500;color:var(--color-text);flex:1;}
|
||||
.mob-body{flex:1;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:7px;background:var(--color-page);}
|
||||
.mob-title-input{font-family:var(--font-display);font-size:13px;color:var(--color-text-muted);font-style:italic;border:none;border-bottom:1px solid var(--color-border);padding:3px 0 5px;width:100%;background:transparent;outline:none;}
|
||||
.mob-collapsible{background:#fff;border:1px solid #e4e2d7;border-radius:3px;overflow:hidden;}
|
||||
.mob-coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:7px 9px;font-size:8.5px;font-weight:600;color:var(--color-text);}
|
||||
.mob-coll-chevron{font-size:9px;color:var(--color-text-muted);}
|
||||
.mob-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:8px 10px;display:flex;gap:6px;flex-shrink:0;}
|
||||
.mob-btn{font-size:8.5px;font-weight:600;padding:7px 0;border-radius:3px;text-align:center;flex:1;}
|
||||
.mob-btn-ghost{border:1px solid var(--color-border);color:var(--color-text);background:#fff;}
|
||||
.mob-btn-primary{background:var(--navy);color:#fff;border:none;}
|
||||
.mob-je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:3px;margin-bottom:4px;overflow:hidden;}
|
||||
.mob-je-drag{width:14px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;}
|
||||
.mob-je-body{flex:1;padding:6px 7px;}
|
||||
.mob-je-title{font-size:8.5px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:1px;}
|
||||
.mob-je-meta{font-size:7px;color:var(--color-text-muted);}
|
||||
.mob-je-note{margin-top:4px;padding:3px 5px;background:var(--color-surface);border-left:2px solid var(--mint);font-size:7.5px;font-style:italic;color:var(--color-text-muted);}
|
||||
.mob-je-interlude{background:var(--orange-tint);border-color:#F0C99A;}
|
||||
.mob-je-interlude-text{font-size:7.5px;font-style:italic;color:var(--color-text);}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ═══ DOC HEADER ═══ -->
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Lesereisen — Journey-Editor</h1>
|
||||
<p>Kuratierungs-Oberfläche für <code>JourneyEditor</code> auf <code>/geschichten/[id]/edit</code> (wenn <code>type === 'JOURNEY'</code>). Geordnete Briefliste mit Drag-to-Reorder, Dokumenten-Picker, Interlude-Notizen und Inline-Annotation-Editing. Ersetzt den TipTap-Editor für den Journey-Typ.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-o">Final Spec</span><br/>
|
||||
2026-06-07 · @leonievoss<br/>
|
||||
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #753</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ JOURNEY HEADER ═══ -->
|
||||
<div class="jh jh-o">
|
||||
<div class="jn">E</div>
|
||||
<div>
|
||||
<h2>Journey-Editor</h2>
|
||||
<p>BLOG_WRITERs kuratieren eine geordnete Briefsequenz — Briefe hinzufügen, Zwischentexte einfügen, Reihenfolge per Drag anpassen, Notizen inline bearbeiten.</p>
|
||||
<div class="fl">/geschichten/[id]/edit (type === 'JOURNEY')</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ KONZEPT ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Konzept</div>
|
||||
<p class="prose">Der <code>JourneyEditor</code> ist eine parallele Implementierung zum bestehenden <code>GeschichteEditor</code> und wird auf derselben Edit-Route eingeblendet wenn <code>type === 'JOURNEY'</code>. Das Split-Layout (70/30) bleibt erhalten: links die Briefliste, rechts die Sidebar mit Personen und Status.</p>
|
||||
<p class="prose">Die linke Fläche zeigt: oben einen optionalen Einleitungs-Textarea (<code>body</code>), darunter die geordnete Itemliste, ganz unten eine Aktionsleiste mit „+ Brief hinzufügen" und „+ Zwischentext hinzufügen". Jedes Item hat einen Drag-Handle, eine Positionsnummer, den Inhalt und einen Entfernen-Button.</p>
|
||||
<p class="prose">Dokument-Items zeigen Titel und Kurz-Metadaten. Eine „Notiz hinzufügen/bearbeiten"-Aktion expandiert ein Textarea direkt unterhalb des Items — kein Modal, kein separates Formular. Interlude-Items (reiner Zwischentext) zeigen direkt ein editierbares Textarea mit orangenem Hintergrund zur klaren visuellen Unterscheidung.</p>
|
||||
<p class="prose">Speicheraktionen: Speichern (bei veröffentlichter Journey) oder „Entwurf speichern" + „Veröffentlichen" (bei DRAFT). Die Savebarlogik ist identisch zum GeschichteEditor. Alle Mutationen lösen sofort einen API-Call aus und aktualisieren den lokalen Zustand optimistisch — kein separates Save für einzelne Items.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-1: EMPTY EDITOR ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Leerer Editor</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-1 — Journey-Editor leer</h3>
|
||||
<span class="scr-id">Issue #753 · LE-1</span>
|
||||
</div>
|
||||
<p class="scr-desc">Ausgangszustand einer neuen oder leeren Lesereise. Titel-Input oben. Darunter ein optionaler Einleitungs-Textarea. Leere Itemliste mit Leerstate-Text. Aktionsleiste mit zwei Buttons. Sidebar: Personen-Verknüpfung und Status-Anzeige. Keine Items → „Veröffentlichen" noch nicht aktiv (Disabled-Hint erscheint).</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Neuer Entwurf ohne Titel (hier gezeigt) · Mit Titel, leere Liste</p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · Neuer Entwurf</span>
|
||||
<div class="desk" style="min-height:500px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-topbar">
|
||||
<div class="ed-back">←</div>
|
||||
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
|
||||
Neue Lesereise
|
||||
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
|
||||
</div>
|
||||
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
|
||||
</div>
|
||||
<div class="ed-split">
|
||||
<!-- Left: Journey editor area -->
|
||||
<div class="je-main">
|
||||
<input class="je-title-input placeholder" type="text" value="" placeholder="Titel der Lesereise" readonly/>
|
||||
<div class="je-sep"></div>
|
||||
<div>
|
||||
<div class="je-intro-label">Einleitung (optional)</div>
|
||||
<textarea class="je-intro-area" placeholder="Optionaler Einleitungstext für diese Lesereise…" readonly></textarea>
|
||||
</div>
|
||||
<div class="je-sep"></div>
|
||||
<div class="je-list-label">Briefe & Zwischentexte</div>
|
||||
<div class="je-empty">
|
||||
<div class="je-empty-text">Noch keine Einträge. Füge den ersten Brief oder einen Zwischentext hinzu.</div>
|
||||
</div>
|
||||
<div class="je-add-bar">
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Brief hinzufügen
|
||||
</button>
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Zwischentext hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Sidebar -->
|
||||
<div class="ed-sidebar">
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Personen</div>
|
||||
<div class="ed-search-row">
|
||||
<span style="font-size:9px;color:var(--color-text-muted);">🔍</span>
|
||||
<div class="ed-search-input">Person suchen…</div>
|
||||
</div>
|
||||
<div class="ed-hint">Welche historischen Personen kommen in dieser Lesereise vor?</div>
|
||||
</div>
|
||||
<div class="ed-sb-divider"></div>
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Status</div>
|
||||
<div class="ed-status-pill ed-status-draft" style="font-size:9px;">ENTWURF</div>
|
||||
<div class="ed-hint" style="margin-top:6px;">Noch nicht öffentlich sichtbar. Füge mindestens einen Brief hinzu, um zu veröffentlichen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-savebar">
|
||||
<span class="ed-savebar-hint">Alle Änderungen werden als Entwurf gespeichert.</span>
|
||||
<div class="ed-savebar-actions">
|
||||
<button class="ed-btn-ghost">Entwurf speichern</button>
|
||||
<button class="ed-btn-primary" style="opacity:.4;cursor:not-allowed;">Veröffentlichen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-1 Leerer Editor</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||
<tr><td>Bedingte Verzweigung</td><td>{#if geschichte.type === 'JOURNEY'}<JourneyEditor />{:else}<GeschichteEditor />{/if}</td><td>in edit/+page.svelte; Props: geschichte: Geschichte</td></tr>
|
||||
<tr><td>Split-Layout</td><td>flex flex-1 overflow-hidden (gleich wie GeschichteEditor)</td><td>70/30; Sidebar identisch</td></tr>
|
||||
<tr><td>Topbar-Badge</td><td>„REISE" Pill neben dem Titel-Label</td><td>orange; kein interaktives Element; zeigt Typ</td></tr>
|
||||
<tr class="grp"><td colspan="3">Titel-Input</td></tr>
|
||||
<tr><td>Titel-Input</td><td>font-serif text-2xl border-b border-line pb-2 w-full bg-transparent outline-none</td><td>bind:value={title}; gleiche Validierung wie GeschichteEditor (required)</td></tr>
|
||||
<tr class="grp"><td colspan="3">Einleitungs-Textarea</td></tr>
|
||||
<tr><td>Intro-Textarea</td><td>font-serif text-sm italic text-ink-3 leading-relaxed w-full resize-none bg-transparent outline-none border-none py-1</td><td>bind:value={body}; plaintext; auto-resize per rows-attr oder JS</td></tr>
|
||||
<tr><td>Label</td><td>text-[10px] font-bold uppercase tracking-widest text-ink-3 mb-1</td><td>„EINLEITUNG (OPTIONAL)"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Leerstate</td></tr>
|
||||
<tr><td>Leerstate-Container</td><td>py-8 text-center border border-dashed border-line rounded-sm bg-surface</td><td>verschwindet sobald erstes Item vorhanden</td></tr>
|
||||
<tr><td>Leerstate-Text</td><td>font-serif text-xs text-ink-3 italic</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Veröffentlichen-Button</td></tr>
|
||||
<tr><td>Disabled-Zustand</td><td>disabled={items.length === 0 || !title.trim()}</td><td>opacity-40 + cursor-not-allowed; keine Tooltip nötig — Sidebar-Hint erklärt es</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-2: EDITOR WITH ITEMS ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Editor mit Einträgen</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-2 — Journey-Editor mit Einträgen</h3>
|
||||
<span class="scr-id">Issue #753 · LE-2</span>
|
||||
</div>
|
||||
<p class="scr-desc">Gefüllte Itemliste mit gemischten Typen: Dokument-Item ohne Notiz, Interlude-Item (reiner Zwischentext), Dokument-Item mit bestehender Notiz. Jedes Item zeigt Drag-Handle links, Positionsnummer, Inhalt und Entfernen-Button. Aktionsleiste bleibt unter der Liste sichtbar.</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Veröffentlichte Journey (hier gezeigt) · Entwurf · Mobile</p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · VERÖFFENTLICHT</span>
|
||||
<div class="desk" style="min-height:580px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">KR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-topbar">
|
||||
<div class="ed-back">←</div>
|
||||
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
|
||||
Lesereise bearbeiten
|
||||
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
|
||||
</div>
|
||||
<div class="ed-status-pill ed-status-pub">VERÖFFENTLICHT</div>
|
||||
<span class="ed-delete-link">Löschen</span>
|
||||
</div>
|
||||
<div class="ed-split">
|
||||
<!-- Left: Journey editor area with items -->
|
||||
<div class="je-main">
|
||||
<input class="je-title-input" type="text" value="Briefe aus Breslau 1938–1942" readonly/>
|
||||
<div class="je-sep"></div>
|
||||
<div>
|
||||
<div class="je-intro-label">Einleitung (optional)</div>
|
||||
<textarea class="je-intro-area" readonly style="color:var(--color-text);">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten Friedenssommern bis zum Ende des Krieges.</textarea>
|
||||
</div>
|
||||
<div class="je-sep"></div>
|
||||
<div class="je-list-label">Briefe & Zwischentexte</div>
|
||||
|
||||
<!-- Item 1: Document, no note -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">1</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="je-doc-meta">12. Juli 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<div class="je-note-add">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Notiz hinzufügen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2: Interlude -->
|
||||
<div class="je-item je-interlude-bg">
|
||||
<div class="je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
|
||||
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num" style="color:var(--orange-dark);">
|
||||
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" style="margin-top:7px;"><path d="M2 4h8M2 7h5" stroke="var(--orange)" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="je-body" style="padding-top:6px;">
|
||||
<div style="font-size:7.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:4px;">Zwischentext</div>
|
||||
<textarea class="je-interlude-area" readonly>Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</textarea>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x" style="color:#D4A574;">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item 3: Document with note -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">2</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
|
||||
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<textarea class="je-note-area" readonly>Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten?</textarea>
|
||||
<div class="je-note-add" style="margin-top:3px;color:var(--color-text-muted);">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
Notiz entfernen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item 4: Document, no note -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">3</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Brief vom 3. September 1939</div>
|
||||
<div class="je-doc-meta">3. Sept. 1939 · von Emma Müller an Franz Raddatz</div>
|
||||
<div class="je-note-add">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Notiz hinzufügen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Add bar -->
|
||||
<div class="je-add-bar">
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Brief hinzufügen
|
||||
</button>
|
||||
<button class="je-add-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Zwischentext hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Sidebar -->
|
||||
<div class="ed-sidebar">
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Personen</div>
|
||||
<div class="ed-search-row">
|
||||
<span style="font-size:9px;color:var(--color-text-muted);">🔍</span>
|
||||
<div class="ed-search-input">Person suchen…</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;margin-top:4px;">
|
||||
<span class="ed-chip">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:var(--mint);">FR</span>
|
||||
Franz Raddatz
|
||||
<span class="ed-chip-x">×</span>
|
||||
</span>
|
||||
<span class="ed-chip">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:#534AB7;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;">EM</span>
|
||||
Emma Müller
|
||||
<span class="ed-chip-x">×</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-sb-divider"></div>
|
||||
<div class="ed-sb-section">
|
||||
<div class="ed-sb-title">Status</div>
|
||||
<div class="ed-status-pill ed-status-pub" style="font-size:9px;">VERÖFFENTLICHT</div>
|
||||
<div class="ed-hint" style="margin-top:6px;">Änderungen gehen sofort live.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-savebar">
|
||||
<span class="ed-savebar-hint">Änderungen sofort live — Leser sehen die aktuelle Version.</span>
|
||||
<div class="ed-savebar-actions">
|
||||
<button class="ed-btn-ghost retract">Zurück zu Entwurf</button>
|
||||
<button class="ed-btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-2 Items-Liste</h4>
|
||||
<table class="at">
|
||||
<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>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>
|
||||
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||
<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 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 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>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>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-3: INLINE NOTE EDITING ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Inline-Notiz-Editing</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-3 — Notiz-Textarea wird geöffnet</h3>
|
||||
<span class="scr-id">Issue #753 · LE-3</span>
|
||||
</div>
|
||||
<p class="scr-desc">Wenn der Nutzer auf „Notiz hinzufügen" klickt, expandiert das Item um ein Textarea direkt unterhalb der Briefmeta — kein Modal. Der Fokus springt automatisch in das Textarea. Das Textarea hat einen blauen Fokusring als Orientierungshilfe. Ein API-PATCH wird beim Verlassen des Textareas (blur) ausgelöst, nicht bei jedem Tastendruck.</p>
|
||||
<p class="scr-var"><strong>Inset-Ansicht — kein vollständiger Seiten-Mockup nötig</strong></p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:560px;">
|
||||
<span class="bp-lbl">Inset — Notiz-Textarea geöffnet (Fokus)</span>
|
||||
<div style="background:#E8E7E2;padding:16px;border-radius:var(--radius-xl);">
|
||||
<!-- Item before (no note) -->
|
||||
<div class="je-item" style="margin-bottom:5px;">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">1</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="je-doc-meta">12. Juli 1938 · Franz → Emma</div>
|
||||
<div class="je-note-add">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
Notiz hinzufügen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Item with opened note textarea (focused) -->
|
||||
<div class="je-item">
|
||||
<div class="je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-num">2</div>
|
||||
<div class="je-body">
|
||||
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
|
||||
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 · Franz → Emma</div>
|
||||
<!-- Focused textarea -->
|
||||
<textarea class="je-note-area je-note-editing" style="outline:none;box-shadow:0 0 0 2px rgba(1,40,81,.2);" readonly placeholder="Kuratoren-Notiz für diesen Brief…">|</textarea>
|
||||
<div style="font-size:7px;color:var(--color-text-muted);margin-top:3px;">Wird gespeichert, wenn du das Feld verlässt.</div>
|
||||
<div class="je-note-add" style="color:var(--color-text-muted);margin-top:2px;">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
Notiz entfernen
|
||||
</div>
|
||||
</div>
|
||||
<div class="je-remove"><div class="je-remove-x">×</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-3 Inline-Notiz</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Toggleverhalten</td></tr>
|
||||
<tr><td>Lokaler State</td><td>let noteOpen = item.note !== null and item.note !== ''</td><td>öffnet sich automatisch wenn Notiz bereits vorhanden</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Klick</td><td>noteOpen = true; tick().then(() => noteTextarea.focus())</td><td>Fokus nach Svelte-Tick um DOM-Update abzuwarten</td></tr>
|
||||
<tr><td>Textarea blur-Handler</td><td>on:blur={() => saveNote(item.id, note)}</td><td>PATCH /api/geschichten/{id}/items/{itemId} mit {note}</td></tr>
|
||||
<tr><td>Leere Notiz on blur</td><td>wenn note.trim() === '' → noteOpen = false; note = null</td><td>verhindert leere Notizen im Backend</td></tr>
|
||||
<tr class="grp"><td colspan="3">Fokus-Styling</td></tr>
|
||||
<tr><td>Fokus-Ring</td><td>focus:border-primary focus:ring-2 focus:ring-primary/20 focus:bg-white</td><td>sichtbarer Ring für Keyboard-Navigation; ring-offset für Abstand</td></tr>
|
||||
<tr><td>Spar-Hint</td><td>text-[9px] text-ink-3 mt-1</td><td>„Wird gespeichert, wenn du das Feld verlässt."; verschwindet wenn noteOpen = false</td></tr>
|
||||
<tr class="grp"><td colspan="3">Barrierefreiheit</td></tr>
|
||||
<tr><td>aria-label Textarea</td><td>aria-label="Kuratoren-Notiz für {document.title}"</td><td>spezifisch; Screen-Reader nennt Brief-Kontext</td></tr>
|
||||
<tr><td>aria-expanded Toggle</td><td>aria-expanded={noteOpen} auf „Notiz hinzufügen"-Button</td><td>kommuniziert Expand-State</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LE-4: MOBILE ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Mobile Editor</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LE-4 — Mobile Journey-Editor</h3>
|
||||
<span class="scr-id">Issue #753 · LE-4</span>
|
||||
</div>
|
||||
<p class="scr-desc">Auf Mobile (320px) entfällt die Sidebar-Split. Die Personen- und Status-Sektion werden als ausklappbare Sektionen unter der Itemliste gezeigt. Drag-to-Reorder ist auf Mobile durch Long-Press aktiviert. Die Aktionsleiste scrollt mit dem Inhalt.</p>
|
||||
<p class="scr-var"><strong>Primäre Zielgruppe für den Editor: Desktop/Tablet. Mobile ist sekundär — alle Funktionen erreichbar, aber Drag ist schwerer bedienbar.</strong></p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<span class="bp-lbl">Mobile — 320px · mit Einträgen</span>
|
||||
<div class="phone" style="min-height:580px;">
|
||||
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||
<div class="pb">
|
||||
<div class="m-nav">
|
||||
<span class="m-logo">ARCHIV</span>
|
||||
<div class="m-nav-r">
|
||||
<div class="m-av">KR</div>
|
||||
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-topbar">
|
||||
<span class="mob-back">←</span>
|
||||
<span class="mob-label">Lesereise bearbeiten</span>
|
||||
<div class="ed-status-pill ed-status-pub" style="font-size:7px;padding:1px 5px;">VERÖFF.</div>
|
||||
</div>
|
||||
<div class="mob-body">
|
||||
<input class="mob-title-input" type="text" value="Briefe aus Breslau 1938–1942" readonly/>
|
||||
|
||||
<!-- Item 1: Document -->
|
||||
<div class="mob-je-item">
|
||||
<div class="mob-je-drag">
|
||||
<div class="je-drag-dots">
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-je-body">
|
||||
<div class="mob-je-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="mob-je-meta">12. Juli 1938 · Franz → Emma</div>
|
||||
</div>
|
||||
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">×</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 2: Interlude -->
|
||||
<div class="mob-je-item mob-je-interlude">
|
||||
<div class="mob-je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;"></div>
|
||||
<div class="mob-je-body">
|
||||
<div style="font-size:6.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:3px;">Zwischentext</div>
|
||||
<div class="mob-je-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht…</div>
|
||||
</div>
|
||||
<div style="padding:6px 6px 0 0;font-size:10px;color:#D4A574;">×</div>
|
||||
</div>
|
||||
|
||||
<!-- Item 3: Document with note -->
|
||||
<div class="mob-je-item">
|
||||
<div class="mob-je-drag"></div>
|
||||
<div class="mob-je-body">
|
||||
<div class="mob-je-title">Postkarte Aug. 1938</div>
|
||||
<div class="mob-je-meta">22. Aug. 1938 · Franz → Emma</div>
|
||||
<div class="mob-je-note">Diese Karte ist ungewöhnlich kurz für Franz…</div>
|
||||
</div>
|
||||
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">×</div>
|
||||
</div>
|
||||
|
||||
<!-- Add bar -->
|
||||
<div style="display:flex;gap:5px;padding:4px 0;">
|
||||
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Brief</button>
|
||||
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Zwischentext</button>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible: Personen -->
|
||||
<div class="mob-collapsible">
|
||||
<div class="mob-coll-hdr">
|
||||
Personen
|
||||
<span class="mob-coll-chevron">›</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Collapsible: Status -->
|
||||
<div class="mob-collapsible">
|
||||
<div class="mob-coll-hdr">
|
||||
Status & Speichern
|
||||
<span class="mob-coll-chevron">›</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mob-savebar">
|
||||
<button class="mob-btn mob-btn-ghost" style="font-size:8px;flex:0 0 auto;padding:7px 10px;">Entwurf</button>
|
||||
<button class="mob-btn mob-btn-primary">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LE-4 Mobile</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Layout-Anpassungen</td></tr>
|
||||
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
|
||||
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
|
||||
<tr class="grp"><td colspan="3">Touch & Drag</td></tr>
|
||||
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr>
|
||||
<tr><td>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>
|
||||
<tr><td>Savebar Mobile</td><td>flex gap-2; „Zurück zu Entwurf" komprimiert zu „Entwurf"</td><td>Volltext passt nicht auf 320px</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Journey-Editor</h2>
|
||||
|
||||
<h3>Neue Komponente</h3>
|
||||
<table>
|
||||
<thead><tr><th>Datei</th><th>Typ</th><th>Beschreibung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>src/lib/geschichte/JourneyEditor.svelte</code></td><td>Svelte-Komponente</td><td>Hauptkomponente; Props: <code>geschichte: Geschichte</code></td></tr>
|
||||
<tr><td><code>src/lib/geschichte/JourneyItemRow.svelte</code></td><td>Svelte-Komponente</td><td>Eine Zeile (Dokument oder Interlude); Props: <code>item: JourneyItem, position: number</code>, Events: <code>remove, noteChange</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Edit-Page-Integration</h3>
|
||||
<ul>
|
||||
<li><code>GeschichteEditor.svelte</code> erhält ein neues Prop <code>type: GeschichteType</code>.</li>
|
||||
<li>Wenn <code>type === 'JOURNEY'</code>: rendere <code>JourneyEditor</code> statt TipTap-Editor. Die Sidebar (Personen, Status, Savebar) bleibt identisch.</li>
|
||||
<li>Die Savebar-Logik ist in der Edit-Page (<code>+page.svelte</code>) verankert — <code>JourneyEditor</code> gibt nur Änderungen nach oben (Svelte-Events oder bindable Props), die Seite hält den Save-State.</li>
|
||||
</ul>
|
||||
|
||||
<h3>API-Calls</h3>
|
||||
<table>
|
||||
<thead><tr><th>Aktion</th><th>Endpoint</th><th>Body</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Brief hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{documentId: UUID}</code></td></tr>
|
||||
<tr><td>Zwischentext hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{note: string}</code></td></tr>
|
||||
<tr><td>Notiz speichern/bearbeiten</td><td><code>PATCH /api/geschichten/{id}/items/{itemId}</code></td><td><code>{note: string | null}</code></td></tr>
|
||||
<tr><td>Item entfernen</td><td><code>DELETE /api/geschichten/{id}/items/{itemId}</code></td><td>—</td></tr>
|
||||
<tr><td>Reihenfolge speichern</td><td><code>PUT /api/geschichten/{id}/items/reorder</code></td><td><code>[{id: UUID, position: number}]</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Optimistische Updates</h3>
|
||||
<ul>
|
||||
<li>Alle Mutationen (add, remove, reorder, noteChange) aktualisieren den lokalen State <em>sofort</em>, der API-Call läuft parallel.</li>
|
||||
<li>Bei Fehler: lokalen State zurückrollen und einen <code>aria-live="polite"</code>-Fehlerhinweis anzeigen.</li>
|
||||
<li>Notiz-Saving ist ein Sonderfall: es gibt kein optimistisches Update da der Wert bereits live im Textarea ist — nur blur → PATCH.</li>
|
||||
</ul>
|
||||
|
||||
<h3>DocumentPicker-Integration</h3>
|
||||
<ul>
|
||||
<li>Der „Brief hinzufügen"-Button öffnet die bestehende <code>DocumentPicker</code>-Komponente (prüfe <code>$lib/document/</code> auf vorhandene Typeahead-Komponenten).</li>
|
||||
<li>Nach Auswahl eines Dokuments: <code>POST /items</code> mit <code>documentId</code>, neues Item wird an das Ende der Liste angehängt und eingeblendet.</li>
|
||||
<li>Bereits in der Journey enthaltene Dokumente: in der Picker-Ergebnisliste mit einem „Bereits enthalten"-Hinweis markieren und deaktivieren.</li>
|
||||
</ul>
|
||||
|
||||
<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>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>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
<ul>
|
||||
<li>Items-Liste: <code><ol></code>-Element — kommuniziert die Ordnung an Screenreader.</li>
|
||||
<li>Drag-Handle: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Reihenfolge von '{title}' ändern"</code>.</li>
|
||||
<li>Entfernen-Button: <code>aria-label="'{title}' entfernen"</code>; kein reines ×-Zeichen ohne Label.</li>
|
||||
<li>Notiz-Textarea: <code>aria-label="Kuratoren-Notiz für '{title}'"</code>.</li>
|
||||
<li>Touch-Targets: alle interaktiven Elemente min 44×44px (WCAG 2.2 AA).</li>
|
||||
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Buttons und Textareas.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Abgrenzung zu GeschichteEditor</h3>
|
||||
<ul>
|
||||
<li>TipTap wird für JOURNEY <em>nicht</em> geladen — kein unnötiger Bundle-Load.</li>
|
||||
<li>Die Sidebar (Personen, Status) ist für beide Typen identisch — kein Duplikat, die Sidebar-Komponente wird geteilt.</li>
|
||||
<li>Savebar-Logik (DRAFT/PUBLISHED/Retract) ist identisch — JourneyEditor ändert sie nicht.</li>
|
||||
<li><code>Geschichte.body</code> dient für JOURNEY als Einleitungstext (Plaintext, kein HTML). Kein Rich-Text-Rendering auf der Leseseite nötig.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
727
docs/specs/lesereisen-reader-spec.html
Normal file
727
docs/specs/lesereisen-reader-spec.html
Normal file
@@ -0,0 +1,727 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Lesereisen — Reader-Integration · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
|
||||
.jh-o .jn{color:var(--orange);}
|
||||
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.fa-link.active{color:var(--mint);}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
|
||||
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
|
||||
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
|
||||
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
|
||||
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
|
||||
|
||||
/* ── impl-ref table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}
|
||||
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
|
||||
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
|
||||
.at tr:last-child td{border-bottom:none;}
|
||||
.at td:first-child{color:#7A7A72;}
|
||||
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
|
||||
.at td:nth-child(3){color:#5A5A55;}
|
||||
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
/* ── LLM guide ── */
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
|
||||
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
/* ── List row (re-used from reader-journey spec) ── */
|
||||
.g-list-card{background:#fff;border:1px solid #E4E2D7;border-radius:4px;box-shadow:var(--shadow-card);overflow:hidden;}
|
||||
.g-row{display:flex;gap:0;border-bottom:1px solid #F0EFE9;}
|
||||
.g-row:last-child{border-bottom:none;}
|
||||
.g-meta{width:88px;flex-shrink:0;padding:10px 10px 10px 12px;display:flex;flex-direction:column;gap:3px;border-right:1px solid #F0EFE9;}
|
||||
.g-content{padding:10px 14px 10px 12px;flex:1;min-width:0;}
|
||||
.g-av{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:#fff;flex-shrink:0;margin-bottom:3px;}
|
||||
.av-navy{background:#012851;} .av-purple{background:#534AB7;} .av-teal{background:#0E9488;}
|
||||
.g-author{font-size:7px;font-weight:700;color:#1C1C18;line-height:1.3;}
|
||||
.g-date{font-size:6.5px;color:#6B6A63;}
|
||||
.g-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;background:#F5F4EE;border:1px solid #D8D7D0;border-radius:10px;font-size:6px;font-weight:500;color:#1C1C18;margin-top:2px;max-width:76px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.g-title{font-family:Georgia,serif;font-size:11px;color:#012851;line-height:1.4;margin-bottom:2px;}
|
||||
.g-excerpt{font-size:7.5px;color:#6B6A63;line-height:1.55;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.g-filters{display:flex;gap:5px;align-items:center;padding:8px 12px;background:var(--color-page);border-bottom:1px solid #EDECEA;flex-wrap:wrap;}
|
||||
.g-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:6.5px;font-weight:700;border:1px solid #D8D7D0;color:#6B6A63;background:transparent;}
|
||||
.g-pill.active{background:#012851;color:#fff;border-color:#012851;}
|
||||
.g-page-hdr{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px;}
|
||||
.g-page-title{font-family:Georgia,serif;font-size:16px;font-weight:400;color:#012851;}
|
||||
.g-new-btn{font-size:7px;font-weight:700;padding:4px 10px;border-radius:3px;background:#012851;color:#fff;border:none;display:flex;align-items:center;gap:3px;}
|
||||
|
||||
/* ── Journey badge in list ── */
|
||||
.j-badge{display:inline-flex;align-items:center;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-top:2px;}
|
||||
|
||||
/* ── Type selector cards ── */
|
||||
.type-selector{display:flex;gap:12px;justify-content:center;padding:20px 24px;flex:1;align-items:center;background:#E8E7E2;}
|
||||
.type-selector-inner{max-width:520px;width:100%;}
|
||||
.type-selector-q{font-family:Georgia,serif;font-size:12px;font-weight:400;color:#6B6A63;text-align:center;margin-bottom:14px;}
|
||||
.type-cards{display:flex;gap:10px;}
|
||||
.type-card{flex:1;border:1px solid #D8D7D0;border-radius:6px;padding:12px 14px;cursor:pointer;background:#fff;display:flex;flex-direction:column;gap:5px;}
|
||||
.type-card.selected{border-color:var(--orange);background:var(--orange-tint);box-shadow:0 0 0 2px rgba(232,134,42,.15);}
|
||||
.type-card-icon{font-size:16px;margin-bottom:2px;}
|
||||
.type-card-title{font-family:Georgia,serif;font-size:11px;font-weight:400;color:var(--navy);}
|
||||
.type-card-desc{font-size:7.5px;color:#6B6A63;line-height:1.55;}
|
||||
.type-card-check{width:14px;height:14px;border-radius:50%;background:var(--orange);display:flex;align-items:center;justify-content:center;margin-top:4px;align-self:flex-end;}
|
||||
.type-card-check svg{width:8px;height:8px;}
|
||||
.type-next-bar{display:flex;justify-content:flex-end;padding:8px 24px;background:#fff;border-top:1px solid #E4E2D7;}
|
||||
.type-next-btn{font-size:8px;font-weight:700;padding:5px 14px;border-radius:3px;background:var(--navy);color:#fff;border:none;display:flex;align-items:center;gap:3px;}
|
||||
|
||||
/* ── Journey reader ── */
|
||||
.jr-article{background:var(--color-page);border-radius:6px;padding:16px 20px;max-width:640px;margin:0 auto;}
|
||||
.jr-back{font-size:7px;color:#6B6A63;margin-bottom:10px;display:flex;align-items:center;gap:2px;}
|
||||
.jr-badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:5px;}
|
||||
.jr-title{font-family:Georgia,serif;font-size:18px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:8px;}
|
||||
.jr-metabar{display:flex;align-items:center;gap:6px;padding-bottom:8px;border-bottom:1px solid #EDECEA;margin-bottom:10px;}
|
||||
.jr-metabar-r{margin-left:auto;display:flex;align-items:center;gap:6px;}
|
||||
.jr-edit-btn{font-size:6.5px;font-weight:600;padding:2px 7px;border:1px solid #D8D7D0;border-radius:3px;color:#1C1C18;background:transparent;}
|
||||
.jr-intro{font-family:Georgia,serif;font-size:8.5px;line-height:1.75;color:#6B6A63;font-style:italic;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed #EDECEA;}
|
||||
|
||||
/* Journey items in reader */
|
||||
.jr-item{display:flex;gap:7px;margin-bottom:9px;align-items:flex-start;}
|
||||
.jr-num{width:18px;height:18px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;margin-top:1px;}
|
||||
.jr-card{flex:1;background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:7px 9px;}
|
||||
.jr-card-title{font-family:Georgia,serif;font-size:9px;color:#012851;line-height:1.3;margin-bottom:2px;font-weight:400;}
|
||||
.jr-card-meta{font-size:6.5px;color:#6B6A63;margin-bottom:5px;}
|
||||
.jr-card-link{font-size:7px;font-weight:600;color:#012851;display:flex;align-items:center;gap:2px;}
|
||||
.jr-annotation{margin-top:6px;padding:5px 7px;border-left:2px solid var(--mint);background:#F5F4EE;border-radius:0 3px 3px 0;}
|
||||
.jr-annotation-text{font-size:7.5px;font-style:italic;color:#6B6A63;line-height:1.55;}
|
||||
.jr-interlude{margin:10px 0 10px 25px;padding:7px 9px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 4px 4px 0;}
|
||||
.jr-interlude-text{font-size:8px;font-style:italic;color:#1C1C18;line-height:1.65;}
|
||||
|
||||
/* Mobile list row */
|
||||
.m-row{padding:9px 10px;border-bottom:1px solid #F0EFE9;background:#fff;}
|
||||
.m-row-top{display:flex;align-items:center;gap:5px;margin-bottom:3px;}
|
||||
.m-author-name{font-size:7px;font-weight:700;color:#1C1C18;}
|
||||
.m-date{font-size:6.5px;color:#6B6A63;margin-left:auto;}
|
||||
.m-title{font-family:Georgia,serif;font-size:10px;color:#012851;line-height:1.4;margin-bottom:2px;}
|
||||
.m-excerpt{font-size:7px;color:#6B6A63;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
|
||||
.m-filters{display:flex;gap:4px;padding:6px 10px;background:var(--color-page);border-bottom:1px solid #EDECEA;overflow-x:auto;flex-wrap:nowrap;}
|
||||
.m-filters::-webkit-scrollbar{display:none;}
|
||||
|
||||
/* Mobile journey reader */
|
||||
.mjr-article{background:#fff;border-radius:6px;padding:12px 12px 16px;}
|
||||
.mjr-back{font-size:7px;color:#6B6A63;margin-bottom:7px;display:flex;align-items:center;gap:2px;}
|
||||
.mjr-badge{display:inline-flex;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:4px;}
|
||||
.mjr-title{font-family:Georgia,serif;font-size:14px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:6px;}
|
||||
.mjr-metabar{display:flex;align-items:center;gap:5px;padding-bottom:6px;border-bottom:1px solid #EDECEA;margin-bottom:8px;}
|
||||
.mjr-intro{font-family:Georgia,serif;font-size:8px;line-height:1.7;color:#6B6A63;font-style:italic;margin-bottom:9px;padding-bottom:7px;border-bottom:1px dashed #EDECEA;}
|
||||
.mjr-item{display:flex;gap:5px;margin-bottom:7px;align-items:flex-start;}
|
||||
.mjr-num{width:14px;height:14px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;margin-top:1px;}
|
||||
.mjr-card{flex:1;background:#F5F4EE;border:1px solid #E4E2D7;border-radius:4px;padding:5px 7px;}
|
||||
.mjr-card-title{font-family:Georgia,serif;font-size:8.5px;color:#012851;line-height:1.3;margin-bottom:1px;}
|
||||
.mjr-card-meta{font-size:6px;color:#6B6A63;margin-bottom:4px;}
|
||||
.mjr-card-link{font-size:6.5px;font-weight:600;color:#012851;}
|
||||
.mjr-interlude{margin:7px 0 7px 19px;padding:5px 7px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 3px 3px 0;}
|
||||
.mjr-interlude-text{font-size:7.5px;font-style:italic;color:#1C1C18;line-height:1.6;}
|
||||
|
||||
/* ── Editor topbar (type selector screen) ── */
|
||||
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
|
||||
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
|
||||
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
|
||||
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
|
||||
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ═══ DOC HEADER ═══ -->
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Lesereisen — Reader-Integration</h1>
|
||||
<p>Typauswahl bei <code>/geschichten/new</code>, Journey-Badge auf der Übersichtsliste und die neue geordnete Leseansicht auf <code>/geschichten/[id]</code> wenn <code>type === 'JOURNEY'</code>. Bestehende Story-Ansichten bleiben unverändert.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-o">Final Spec</span><br/>
|
||||
2026-06-07 · @leonievoss<br/>
|
||||
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #752</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ JOURNEY HEADER ═══ -->
|
||||
<div class="jh jh-o">
|
||||
<div class="jn">R</div>
|
||||
<div>
|
||||
<h2>Lesereisen — Reader</h2>
|
||||
<p>Alle angemeldeten Familienmitglieder können Lesereisen entdecken und in Briefsequenzen mit Kuratoren-Notizen eintauchen. BLOG_WRITERs sehen zusätzlich Bearbeiten/Löschen-Aktionen.</p>
|
||||
<div class="fl">/geschichten · /geschichten/new · /geschichten/[id]</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ KONZEPT ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Konzept</div>
|
||||
<p class="prose">Eine <em>Lesereise</em> ist eine <code>Geschichte</code> mit <code>type === 'JOURNEY'</code>. Ihr Kerninhalt ist eine geordnete Sequenz von Briefen (<code>JourneyItem</code>s mit <code>document_id</code>) und Zwischentexten (<code>JourneyItem</code>s ohne <code>document_id</code>). Das optionale Feld <code>body</code> dient als Einleitung/Preface.</p>
|
||||
<p class="prose">Diese Spec deckt drei Änderungen ab: (1) die Typauswahl auf <code>/geschichten/new</code> als vorgelagerter Schritt, (2) das „REISE"-Badge in der Übersichtsliste, und (3) die neue Journey-Leseansicht auf der Detailseite, die den bestehenden Prosa-Body durch eine nummerierte Briefliste ersetzt.</p>
|
||||
<p class="prose">Dokument-Items zeigen Titel, Datum, Sender→Empfänger und einen Link zum Brief. Optionale Kuratoren-Notizen erscheinen als Annotation mit Mint-Linker-Rand unter dem Briefeintrag. Interlude-Items (kein Dokument) erscheinen als eingerückte Absätze mit orangenem linken Rand — klar vom Dokumenttyp unterscheidbar, aber harmonisch im Lesefluss.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LR-0: TYPE SELECTOR ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Typauswahl</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LR-0 — Typauswahl /geschichten/new</h3>
|
||||
<span class="scr-id">Issue #752 · LR-0</span>
|
||||
</div>
|
||||
<p class="scr-desc">Neuer vorgelagerter Schritt beim Erstellen einer Geschichte. Zwei Karten zur Auswahl: „Geschichte" (Prosa) und „Lesereise" (Briefsequenz). Die ausgewählte Karte wird hervorgehoben. Erst nach Auswahl wird der „Weiter"-Button aktiv. Auswahl bleibt im URL-Param erhalten (<code>?type=JOURNEY</code>).</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Keine Auswahl (Weiter-Button inaktiv) · Lesereise gewählt (hier gezeigt) · Geschichte gewählt</p>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · Lesereise gewählt</span>
|
||||
<div class="desk" style="min-height:320px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ed-topbar">
|
||||
<div class="ed-back">←</div>
|
||||
<div class="ed-title-label">Neue Geschichte</div>
|
||||
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
|
||||
</div>
|
||||
<div class="type-selector">
|
||||
<div class="type-selector-inner">
|
||||
<div class="type-selector-q">Was möchtest du erstellen?</div>
|
||||
<div class="type-cards">
|
||||
<!-- Story card -->
|
||||
<div class="type-card">
|
||||
<div class="type-card-icon">✍️</div>
|
||||
<div class="type-card-title">Geschichte</div>
|
||||
<div class="type-card-desc">Freier Prosatext über Familienerlebnisse, Erinnerungen oder historische Einordnungen — mit verlinkten Personen und Dokumenten.</div>
|
||||
</div>
|
||||
<!-- Journey card (selected) -->
|
||||
<div class="type-card selected">
|
||||
<div class="type-card-icon">📜</div>
|
||||
<div class="type-card-title">Lesereise</div>
|
||||
<div class="type-card-desc">Geordnete Briefsequenz mit optionalen Kuratoren-Notizen zwischen den Briefen — für chronologische Korrespondenz-Sammlungen.</div>
|
||||
<div class="type-card-check">
|
||||
<svg viewBox="0 0 10 10" fill="none"><path d="M2 5l2.5 2.5L8 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="type-next-bar">
|
||||
<button class="type-next-btn">
|
||||
Weiter
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LR-0 Typauswahl</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Layout</td></tr>
|
||||
<tr><td>Selector area</td><td>flex flex-1 items-center justify-center bg-canvas px-6 py-10</td><td>zentriert, füllt restliche Höhe</td></tr>
|
||||
<tr><td>Frage</td><td>font-serif text-sm text-ink-2 text-center mb-4</td><td></td></tr>
|
||||
<tr><td>Karten-Grid</td><td>flex gap-4</td><td>2 gleich breite Karten; auf Mobile flex-col</td></tr>
|
||||
<tr class="grp"><td colspan="3">Type-Karte</td></tr>
|
||||
<tr><td>Karte (inaktiv)</td><td>border border-line rounded-md p-4 bg-white cursor-pointer hover:border-primary hover:bg-surface</td><td>focus-visible:ring-2 focus-visible:ring-primary</td></tr>
|
||||
<tr><td>Karte (ausgewählt)</td><td>border-2 border-orange-500 bg-orange-50 shadow-sm</td><td>aria-pressed="true"; kein Tailwind-Kürzel — nutze CSS-var(--orange)</td></tr>
|
||||
<tr><td>Check-Kreis</td><td>w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center self-end mt-2</td><td>nur sichtbar wenn ausgewählt</td></tr>
|
||||
<tr><td>Kartentitel</td><td>font-serif text-sm text-ink</td><td></td></tr>
|
||||
<tr><td>Kartenbeschreibung</td><td>text-xs text-ink-3 leading-relaxed mt-1</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Navigation</td></tr>
|
||||
<tr><td>Weiter-Button</td><td>rounded border border-primary bg-primary text-white px-4 py-2 text-sm font-medium disabled:opacity-40</td><td>disabled wenn keine Karte ausgewählt</td></tr>
|
||||
<tr><td>URL-Param</td><td>?type=STORY | ?type=JOURNEY</td><td>per goto() nach Klick auf Weiter; lesefreundlich bookmarkbar</td></tr>
|
||||
<tr><td>Mobile</td><td>flex-col Karten; volle Breite</td><td>kein Scrollbedarf auf 320px</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LR-1: LIST WITH BADGE ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Übersichtsliste</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LR-1 — Reise-Badge in /geschichten</h3>
|
||||
<span class="scr-id">Issue #752 · LR-1</span>
|
||||
</div>
|
||||
<p class="scr-desc">Die Übersichtsliste erhält ein kleines „REISE"-Badge in der Metaspalte einer Journey-Zeile — unterhalb von Datum und Personenchip. Zeilen mit <code>type === 'STORY'</code> bleiben unverändert. Das Badge ist nicht klickbar, dient als reine visuelle Unterscheidung.</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Mischte Liste (hier gezeigt) · Nur-Journey-Filter · Nur-Story-Ansicht (unverändert)</p>
|
||||
|
||||
<div class="previews">
|
||||
<!-- Desktop -->
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · gemischte Liste</span>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;padding:14px 16px;">
|
||||
<div class="g-page-hdr" style="padding:0 0 8px;">
|
||||
<span class="g-page-title">Geschichten</span>
|
||||
<button class="g-new-btn">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
Neue Geschichte
|
||||
</button>
|
||||
</div>
|
||||
<div class="g-list-card">
|
||||
<div class="g-filters">
|
||||
<span class="g-pill active">Alle</span>
|
||||
<span class="g-pill">Franz Raddatz</span>
|
||||
<span class="g-pill">Emma Müller</span>
|
||||
<span class="g-pill" style="border-style:dashed;color:#6B6A63;">+ Person wählen</span>
|
||||
</div>
|
||||
<!-- Row 1: Story (no badge) -->
|
||||
<div class="g-row">
|
||||
<div class="g-meta">
|
||||
<div class="g-av av-navy">MR</div>
|
||||
<div class="g-author">Maria Raddatz</div>
|
||||
<div class="g-date">14. März 2025</div>
|
||||
<span class="g-chip">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
|
||||
Franz Raddatz
|
||||
</span>
|
||||
</div>
|
||||
<div class="g-content">
|
||||
<div class="g-title">Der Sommer in Breslau</div>
|
||||
<div class="g-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg, als die Familie noch vollständig zusammen war und niemand ahnte, was kommen würde…</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 2: Journey (badge!) -->
|
||||
<div class="g-row">
|
||||
<div class="g-meta">
|
||||
<div class="g-av av-purple">KR</div>
|
||||
<div class="g-author">Klaus Raddatz</div>
|
||||
<div class="g-date">15. Mai 2025</div>
|
||||
<span class="g-chip">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
|
||||
Franz Raddatz
|
||||
</span>
|
||||
<span class="j-badge">REISE</span>
|
||||
</div>
|
||||
<div class="g-content">
|
||||
<div class="g-title">Briefe aus Breslau 1938–1942</div>
|
||||
<div class="g-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma — von den letzten Friedenssommern bis zum Ende des Krieges.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3: Story -->
|
||||
<div class="g-row">
|
||||
<div class="g-meta">
|
||||
<div class="g-av av-teal">GK</div>
|
||||
<div class="g-author">Gertrud Koch</div>
|
||||
<div class="g-date">18. Okt. 2024</div>
|
||||
<span class="g-chip">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:#534AB7;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:#fff;flex-shrink:0;">EM</span>
|
||||
Emma Müller
|
||||
</span>
|
||||
</div>
|
||||
<div class="g-content">
|
||||
<div class="g-title">Die Hochzeit im Krieg</div>
|
||||
<div class="g-excerpt">1943, mitten im Chaos — Emma bestand darauf, dass das Fest stattfand. Ihr Bruder kam auf Fronturlaub, drei Tage nur, aber es reichte…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="prev-col">
|
||||
<span class="bp-lbl">Mobile — 320px</span>
|
||||
<div class="phone">
|
||||
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||
<div class="pb">
|
||||
<div class="m-nav">
|
||||
<span class="m-logo">ARCHIV</span>
|
||||
<div class="m-nav-r">
|
||||
<div class="m-av">MR</div>
|
||||
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;display:flex;flex-direction:column;">
|
||||
<div style="padding:8px 10px 4px;">
|
||||
<span style="font-family:Georgia,serif;font-size:13px;color:#012851;">Geschichten</span>
|
||||
</div>
|
||||
<div class="m-filters">
|
||||
<span class="g-pill active" style="font-size:6px;padding:2px 7px;">Alle</span>
|
||||
<span class="g-pill" style="font-size:6px;padding:2px 7px;">Franz Raddatz</span>
|
||||
<span class="g-pill" style="font-size:6px;padding:2px 7px;border-style:dashed;">+ Person…</span>
|
||||
</div>
|
||||
<div style="background:#fff;flex:1;">
|
||||
<!-- Story row -->
|
||||
<div class="m-row">
|
||||
<div class="m-row-top">
|
||||
<div class="g-av av-navy" style="width:16px;height:16px;font-size:5.5px;">MR</div>
|
||||
<span class="m-author-name">Maria Raddatz</span>
|
||||
<span class="m-date">14. Mrz. 2025</span>
|
||||
</div>
|
||||
<div class="m-title">Der Sommer in Breslau</div>
|
||||
<div class="m-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg…</div>
|
||||
</div>
|
||||
<!-- Journey row (badge) -->
|
||||
<div class="m-row">
|
||||
<div class="m-row-top">
|
||||
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;">KR</div>
|
||||
<span class="m-author-name">Klaus Raddatz</span>
|
||||
<span class="m-date">15. Mai 2025</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;">
|
||||
<div class="m-title" style="margin-bottom:0;">Briefe aus Breslau 1938–1942</div>
|
||||
<span class="j-badge" style="flex-shrink:0;">REISE</span>
|
||||
</div>
|
||||
<div class="m-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LR-1 Journey-Badge in der Liste</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Badge</td></tr>
|
||||
<tr><td>Journey badge</td><td>inline-flex items-center px-1.5 py-px rounded-sm text-[10px] font-bold uppercase tracking-wide bg-orange-50 text-orange-700 border border-orange-200</td><td>nur wenn type === 'JOURNEY'</td></tr>
|
||||
<tr><td>Position Desktop</td><td>unterhalb Datum-Text und Personenchip in der Metaspalte (g-meta)</td><td>kein extra Abstand nötig — gap-1 der Flex-Spalte reicht</td></tr>
|
||||
<tr><td>Position Mobile</td><td>inline flex items-center gap-1.5 neben Titel</td><td>Titel + Badge in einem flex-Wrapper; badge shrink-0</td></tr>
|
||||
<tr><td>aria-label</td><td>aria-label="Lesereise"</td><td>Badge ist span, kein interaktives Element</td></tr>
|
||||
<tr class="grp"><td colspan="3">Bedingte Logik</td></tr>
|
||||
<tr><td>Svelte guard</td><td>{#if geschichte.type === 'JOURNEY'}<span …>REISE</span>{/if}</td><td>kein Badge für STORY</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ SCREEN LR-2: JOURNEY READER ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Screens — Journey-Leseansicht</div>
|
||||
|
||||
<div class="scr">
|
||||
<div class="scr-head">
|
||||
<h3>LR-2 — Journey-Detail /geschichten/[id]</h3>
|
||||
<span class="scr-id">Issue #752 · LR-2</span>
|
||||
</div>
|
||||
<p class="scr-desc">Wenn <code>type === 'JOURNEY'</code> ersetzt die geordnete Briefliste den Prosa-Body. Optional zeigt ein Einleitungsabsatz (<code>body</code>) vor den Items. Jedes Item ist entweder ein Briefeintrag (Kartentitel, Datum, Link) oder ein Interlude-Absatz (orangener linker Rand, kursiv). Die Reihenfolge ergibt sich von oben nach unten — keine Nummern. Briefeinträge können eine optionale Kuratoren-Annotation unter dem Link zeigen.</p>
|
||||
<p class="scr-var"><strong>Varianten:</strong> Leserin ohne Schreibrecht · BLOG_WRITER (Bearbeiten/Löschen sichtbar — hier gezeigt) · Mobile</p>
|
||||
|
||||
<div class="previews">
|
||||
<!-- Desktop -->
|
||||
<div class="prev-col" style="width:100%;max-width:1040px;">
|
||||
<span class="bp-lbl">Desktop — 1040px · BLOG_WRITER-Ansicht</span>
|
||||
<div class="desk" style="min-height:600px;">
|
||||
<div class="fa-nav">
|
||||
<span class="fa-logo">ARCHIV</span>
|
||||
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
|
||||
<span class="fa-link">Dokumente</span>
|
||||
<span class="fa-link">Personen</span>
|
||||
<span class="fa-link active">Geschichten</span>
|
||||
<span class="fa-link">Chronik</span>
|
||||
<div class="fa-nav-r">
|
||||
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;padding:16px 20px;">
|
||||
<div class="jr-article">
|
||||
<div class="jr-back">
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Zurück zu Geschichten
|
||||
</div>
|
||||
<div class="jr-badge">LESEREISE</div>
|
||||
<div class="jr-title">Briefe aus Breslau 1938–1942</div>
|
||||
<div class="jr-metabar">
|
||||
<div class="g-av av-purple" style="width:20px;height:20px;font-size:6.5px;">KR</div>
|
||||
<div>
|
||||
<div style="font-size:7.5px;font-weight:700;color:#1C1C18;line-height:1.2;">Klaus Raddatz</div>
|
||||
<div style="font-size:6.5px;color:#6B6A63;">zusammengestellt am 15. Mai 2025</div>
|
||||
</div>
|
||||
<div class="jr-metabar-r">
|
||||
<button class="jr-edit-btn">Bearbeiten</button>
|
||||
<span style="font-size:6.5px;font-weight:600;color:#DC4C3E;">Löschen</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Intro -->
|
||||
<div class="jr-intro">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten unbeschwerten Sommerwochen 1938 bis zum Kriegsende. Diese Lesereise folgt den Briefen in chronologischer Reihenfolge.</div>
|
||||
<!-- Item 1: Document, no annotation -->
|
||||
<div class="jr-item">
|
||||
<div class="jr-card">
|
||||
<div class="jr-card-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="jr-card-meta">12. Juli 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<div class="jr-card-link">
|
||||
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||
Brief öffnen
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Interlude -->
|
||||
<div class="jr-interlude">
|
||||
<div class="jr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde. Seine Briefe aus dieser Zeit tragen eine Leichtigkeit, die in den späteren Kriegsjahren vollständig verschwindet.</div>
|
||||
</div>
|
||||
<!-- Item 2: Document with annotation -->
|
||||
<div class="jr-item">
|
||||
<div class="jr-card">
|
||||
<div class="jr-card-title">Postkarte aus Breslau, August 1938</div>
|
||||
<div class="jr-card-meta">22. Aug. 1938 · von Franz Raddatz an Emma Müller</div>
|
||||
<div class="jr-card-link">
|
||||
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||
Brief öffnen
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div class="jr-annotation">
|
||||
<div class="jr-annotation-text">Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten, oder schlicht die Hitze des Augusts?</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item 3: Document -->
|
||||
<div class="jr-item">
|
||||
<div class="jr-card">
|
||||
<div class="jr-card-title">Brief vom 3. September 1939</div>
|
||||
<div class="jr-card-meta">3. Sept. 1939 · von Emma Müller an Franz Raddatz</div>
|
||||
<div class="jr-card-link">
|
||||
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
|
||||
Brief öffnen
|
||||
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="prev-col">
|
||||
<span class="bp-lbl">Mobile — 320px · Leserin</span>
|
||||
<div class="phone" style="min-height:520px;">
|
||||
<div class="pst"><b>9:41</b><span>●●●</span></div>
|
||||
<div class="pb">
|
||||
<div class="m-nav">
|
||||
<span class="m-logo">ARCHIV</span>
|
||||
<div class="m-nav-r">
|
||||
<div class="m-av">MR</div>
|
||||
<div class="m-ham"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:#E8E7E2;flex:1;padding:10px;">
|
||||
<div class="mjr-article">
|
||||
<div class="mjr-back">
|
||||
<svg width="6" height="6" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Zurück
|
||||
</div>
|
||||
<div class="mjr-badge">LESEREISE</div>
|
||||
<div class="mjr-title">Briefe aus Breslau 1938–1942</div>
|
||||
<div class="mjr-metabar">
|
||||
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;flex-shrink:0;">KR</div>
|
||||
<div>
|
||||
<div style="font-size:7px;font-weight:700;color:#1C1C18;">Klaus Raddatz</div>
|
||||
<div style="font-size:6px;color:#6B6A63;">15. Mai 2025</div>
|
||||
</div>
|
||||
<div style="margin-left:auto;font-size:12px;color:#6B6A63;">···</div>
|
||||
</div>
|
||||
<div style="height:1px;background:#EDECEA;margin-bottom:8px;"></div>
|
||||
<div class="mjr-intro">Der Briefwechsel zwischen Franz und Emma — von 1938 bis Kriegsende.</div>
|
||||
<!-- Item 1 -->
|
||||
<div class="mjr-item">
|
||||
<div class="mjr-card">
|
||||
<div class="mjr-card-title">Brief vom 12. Juli 1938</div>
|
||||
<div class="mjr-card-meta">12. Juli 1938 · Franz → Emma</div>
|
||||
<div class="mjr-card-link">Brief öffnen →</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Interlude -->
|
||||
<div class="mjr-interlude">
|
||||
<div class="mjr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</div>
|
||||
</div>
|
||||
<!-- Item 2 -->
|
||||
<div class="mjr-item">
|
||||
<div class="mjr-card">
|
||||
<div class="mjr-card-title">Postkarte Aug. 1938</div>
|
||||
<div class="mjr-card-meta">22. Aug. 1938 · Franz → Emma</div>
|
||||
<div class="mjr-card-link">Brief öffnen →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>impl-ref — LR-2 Journey-Leseansicht</h4>
|
||||
<table class="at">
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<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>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 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 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 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 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 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>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Lesereisen Reader</h2>
|
||||
|
||||
<h3>Geänderte Views und Routen</h3>
|
||||
<table>
|
||||
<thead><tr><th>View</th><th>Route</th><th>Änderung</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Neue Geschichte</td><td>/geschichten/new</td><td>Neuer Typauswahl-Schritt als first render; setzt ?type=STORY|JOURNEY</td></tr>
|
||||
<tr><td>Geschichten-Liste</td><td>/geschichten</td><td>Journey-Badge in GeschichtenCard wenn type === 'JOURNEY'</td></tr>
|
||||
<tr><td>Geschichte-Detail</td><td>/geschichten/[id]</td><td>Bedingte Verzweigung: JourneyReader | StoryReader</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Neue Komponenten</h3>
|
||||
<ul>
|
||||
<li><code>JourneyReader.svelte</code> — rendert Intro + Items-Liste; Props: <code>geschichte: GeschichteDetail</code></li>
|
||||
<li><code>JourneyItemCard.svelte</code> — ein Dokument-Item mit optionaler Annotation; Props: <code>item: JourneyItem, position: number</code></li>
|
||||
<li><code>JourneyInterlude.svelte</code> — ein reiner Text-Interlude; Props: <code>note: string</code></li>
|
||||
</ul>
|
||||
|
||||
<h3>Datenmodell (nach #750)</h3>
|
||||
<ul>
|
||||
<li><code>GeschichteType: 'STORY' | 'JOURNEY'</code></li>
|
||||
<li><code>JourneyItem: { id: UUID, position: number, document: DocumentSummary | null, note: string | null }</code></li>
|
||||
<li><code>Geschichte.items</code> — geordnete Liste (nach <code>position</code> ASC); für STORY leer</li>
|
||||
<li><code>Geschichte.body</code> — für JOURNEY der optionale Einleitungstext (plaintext, kein HTML); für STORY der Rich-Text-Body</li>
|
||||
</ul>
|
||||
|
||||
<h3>Typauswahl — Implementierungshinweise</h3>
|
||||
<ul>
|
||||
<li>Die Typauswahl ist ein Schritt INNERHALB der <code>/geschichten/new</code>-Route — kein eigener URL, kein <code>goto()</code>. Zustand <code>let selectedType: GeschichteType | null = null</code> in der Komponente.</li>
|
||||
<li>Erst wenn <code>selectedType !== null</code> ist der „Weiter"-Button aktiviert (<code>disabled={!selectedType}</code>).</li>
|
||||
<li>Nach Klick auf „Weiter": wenn <code>selectedType === 'JOURNEY'</code> → <code>goto('/geschichten/new?type=JOURNEY')</code> und zeige den Journey-Editor (aus Issue #753); wenn <code>STORY</code> → bestehender GeschichteEditor (unverändert).</li>
|
||||
<li>Die Karten verwenden <code>role="radio"</code> und <code>aria-checked</code> für Accessibility. Keyboard: Arrow-Keys wechseln zwischen den Karten, Space/Enter wählt aus.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Journey-Badge — Implementierungshinweise</h3>
|
||||
<ul>
|
||||
<li>Badge nur in <code>GeschichtenCard.svelte</code> hinzufügen — keine Änderung an der Listenlogik oder dem API-Aufruf.</li>
|
||||
<li>Text: „REISE" (Kurzform für die Metaspalte); <code>aria-label="Lesereise"</code> für den Badge-Span.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Journey-Reader — Implementierungshinweise</h3>
|
||||
<ul>
|
||||
<li>Items werden bereits geordnet vom Backend geliefert (<code>ORDER BY position ASC</code>). Keine client-seitige Sortierung nötig.</li>
|
||||
<li>Ein Item ist Interlude wenn <code>item.document === null</code>. In diesem Fall: <code>JourneyInterlude</code>-Komponente rendern.</li>
|
||||
<li>Der Intro-Absatz (<code>body</code>) wird als Plaintext gerendert — <em>nicht</em> als innerHTML. Im Editor wird es als einfaches Textarea gespeichert, kein HTML.</li>
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
<ul>
|
||||
<li>Items-Liste: <code><ol></code> semantisch für die geordnete Briefliste. Interludes sind <code><li></code>-Elemente mit <code>aria-label="Kuratorennotiz"</code>.</li>
|
||||
<li>„Brief öffnen"-Link: beschreibender Text mit Briefdatum im <code>aria-label</code>, z.B. <code>aria-label="Brief vom 12. Juli 1938 öffnen"</code>.</li>
|
||||
<li>Touch-Targets: jede Dokumentkarte hat mindestens 44px Höhe durch den Padding der Karte.</li>
|
||||
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Links.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1212
docs/specs/nl-search-spec.html
Normal file
1212
docs/specs/nl-search-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1257
docs/superpowers/plans/2026-06-07-spacy-nlp-service.md
Normal file
1257
docs/superpowers/plans/2026-06-07-spacy-nlp-service.md
Normal file
File diff suppressed because it is too large
Load Diff
188
docs/superpowers/specs/2026-06-07-spacy-nlp-service-design.md
Normal file
188
docs/superpowers/specs/2026-06-07-spacy-nlp-service-design.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# spaCy NLP Service — Design Spec
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Prototype
|
||||
|
||||
## Problem
|
||||
|
||||
The current NL search uses Ollama (`qwen2.5:7b-instruct-q4_K_M`) to parse free-text queries into structured extractions (person names, dates, role, keywords). Inference takes 5–15 seconds per query, making the feature too slow to be useful compared to filling in the filter UI manually.
|
||||
|
||||
## Goal
|
||||
|
||||
Build a standalone `nlp-service/` prototype that replaces Ollama with spaCy for query parsing. The prototype is scoped to **extraction quality evaluation** — run it locally, curl it with real archive queries, and measure whether spaCy extracts names/dates/keywords well enough to justify a full migration. No Java-side changes in this iteration.
|
||||
|
||||
## Extraction Contract
|
||||
|
||||
The service must produce an output compatible with the existing `OllamaExtraction` Java record:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `personNames` | `string[]` | Names of persons mentioned, left-to-right order |
|
||||
| `personRole` | `"sender"` \| `"receiver"` \| `"any"` | Role of the person(s) in the document |
|
||||
| `dateFrom` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
|
||||
| `dateTo` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
|
||||
| `keywords` | `string[]` | Content words — fuzzy-matched against tags by Java |
|
||||
| `rawQuery` | `string` | Echo of the input query |
|
||||
|
||||
**Two-person ordering:** `personNames` must be in left-to-right span order. Java maps `[0]` → sender, `[1]` → receiver.
|
||||
|
||||
**`rawQuery` note:** In the current Java code `rawQuery` is set by the caller, not parsed from Ollama. The service echoes the input for convenience; the eventual `RestClientSpacyClient` will set it from the input directly, same as today.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
nlp-service/
|
||||
├── main.py # FastAPI app — /parse and /health endpoints
|
||||
├── extractor.py # NLP pipeline: NER → role → dates → keywords
|
||||
├── models.py # Pydantic request/response types
|
||||
├── requirements.txt
|
||||
├── Dockerfile
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
Sits alongside `ocr-service/` in the repo. For the prototype it runs standalone (no docker-compose wiring).
|
||||
|
||||
## Extraction Pipeline (`extractor.py`)
|
||||
|
||||
Five steps run in sequence on each query.
|
||||
|
||||
### Step 1 — NER pass
|
||||
|
||||
Run spaCy on the query using the model for the requested language. Collect:
|
||||
- All `PER` spans → candidates for `personNames`
|
||||
- All `DATE` spans → raw text strings for step 3
|
||||
|
||||
### Step 2 — Role detection
|
||||
|
||||
Only relevant when exactly **one** PER entity is found. Walk the dependency tree of the PER span's root token; check if a governing `case` or `prep` token matches the sender or receiver preposition set for the language:
|
||||
|
||||
| Language | Sender prepositions | Receiver prepositions |
|
||||
|---|---|---|
|
||||
| `de` | von, vom | an, nach, für |
|
||||
| `en` | from, by | to, for |
|
||||
| `es` | de, por | para, a |
|
||||
|
||||
- One person + sender preposition → `personRole = "sender"`
|
||||
- One person + receiver preposition → `personRole = "receiver"`
|
||||
- One person + no match / two or more persons → `personRole = "any"`
|
||||
|
||||
Two-person queries always return `"any"` — Java derives direction from position.
|
||||
|
||||
### Step 3 — Date parsing
|
||||
|
||||
For each DATE span, inspect the token immediately before the span to detect range direction:
|
||||
|
||||
| Direction token | Effect |
|
||||
|---|---|
|
||||
| vor / before / antes de | Span → `dateTo` |
|
||||
| nach / after / después de | Span → `dateFrom` |
|
||||
| zwischen…und / between…and / entre…y | Earlier span → `dateFrom`, later → `dateTo` |
|
||||
| No direction token (bare year/date) | Span → both `dateFrom` and `dateTo` set to that year (year-range, Jan 1–Dec 31) |
|
||||
|
||||
`dateparser.parse()` with `PREFER_DAY_OF_MONTH=first` converts the span text to a Python `date`. For `dateTo` results that resolve to a year boundary, set to Dec 31 of that year (mirrors `RestClientOllamaClient.parseDate()` behaviour).
|
||||
|
||||
Output as ISO strings (`YYYY-MM-DD`) or `null`.
|
||||
|
||||
### Step 4 — Keyword extraction
|
||||
|
||||
Collect tokens that satisfy all of:
|
||||
- POS tag is `NOUN` or `PROPN`
|
||||
- Not a stopword
|
||||
- Not inside any NER span (PER or DATE)
|
||||
- Lemma length ≥ 3
|
||||
|
||||
Output as lowercased lemmas. These are fuzzy-matched against the tags table by `NlQueryParserService.resolveTags()` on the Java side — no tag lookup in the Python service.
|
||||
|
||||
Examples:
|
||||
- "Briefe aus dem Krieg" → `keywords: ["brief", "krieg"]`
|
||||
- "Texte über Weihnachten" → `keywords: ["text", "weihnachten"]`
|
||||
|
||||
### Step 5 — Assembly
|
||||
|
||||
```json
|
||||
{
|
||||
"personNames": ["Opa Hermann", "Marie"],
|
||||
"personRole": "any",
|
||||
"dateFrom": null,
|
||||
"dateTo": "1920-12-31",
|
||||
"keywords": ["brief"],
|
||||
"rawQuery": "Briefe von Opa Hermann an Marie vor 1920"
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `POST /parse`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de" }
|
||||
```
|
||||
|
||||
`lang` is a required enum: `"de"` | `"en"` | `"es"`. Unknown values → HTTP 422 (FastAPI validation).
|
||||
|
||||
**Response:** extraction object as above, HTTP 200.
|
||||
|
||||
**Error:** pipeline crash → HTTP 500 `{"detail": "..."}`.
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Returns HTTP 200 `{"status": "ok"}` when all three models are loaded.
|
||||
|
||||
## Language Models
|
||||
|
||||
| `lang` | spaCy model |
|
||||
|---|---|
|
||||
| `de` | `de_core_news_sm` |
|
||||
| `en` | `en_core_web_sm` |
|
||||
| `es` | `es_core_news_sm` |
|
||||
|
||||
All three models are loaded at startup and held in memory. Routing is by the `lang` field on the request.
|
||||
|
||||
## Dockerfile
|
||||
|
||||
Mirrors `ocr-service/` — `python:3.11-slim`, non-root user, models baked into the image:
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN python -m spacy download de_core_news_sm \
|
||||
&& python -m spacy download en_core_web_sm \
|
||||
&& python -m spacy download es_core_news_sm
|
||||
COPY . .
|
||||
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1001 nlp \
|
||||
&& chown -R nlp:nlp /app
|
||||
USER nlp
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
```
|
||||
|
||||
Image size: ~350 MB. No volume needed — models live in the image layer.
|
||||
|
||||
## Local Dev
|
||||
|
||||
```bash
|
||||
cd nlp-service
|
||||
pip install -r requirements.txt
|
||||
python -m spacy download de_core_news_sm en_core_web_sm es_core_news_sm
|
||||
uvicorn main:app --reload --port 8001
|
||||
|
||||
curl -X POST http://localhost:8001/parse \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de"}'
|
||||
```
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Historical names:** spaCy models are trained on modern news corpora. Unusual 1899–1950 German names may not score as `PER`. Mitigation: the Java `resolveNames()` already does fuzzy matching against the persons table, so partial name extraction is recoverable.
|
||||
- **Role detection:** the preposition sets are a fixed enumeration (~12 tokens across 3 languages). Sentences that express direction without one of these prepositions will fall through to `personRole = "any"`. This is acceptable — `"any"` is the safe default and searches both sender and receiver positions.
|
||||
- **"über Oma" ambiguity:** if spaCy recognises "Oma" as a PER entity it lands in `personNames` (person search); if not, it lands in `keywords` (tag search via Java). Both paths return relevant results. The prototype evaluation will reveal which path dominates for real archive queries.
|
||||
|
||||
## Out of Scope (prototype)
|
||||
|
||||
- docker-compose integration (Ollama replacement)
|
||||
- Java-side changes (`RestClientSpacyClient`, rename `OllamaClient` → `NlParserClient`)
|
||||
- Tag lookup inside the Python service
|
||||
- Automated test suite (pytest fixtures) — evaluation is done by curling the running service
|
||||
@@ -22,9 +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.",
|
||||
"error_validation_error": "Die Eingabe ist ungültig.",
|
||||
"error_internal_error": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
"nav_documents": "Dokumente",
|
||||
@@ -1026,6 +1023,11 @@
|
||||
"nav_stammbaum": "Stammbaum",
|
||||
"nav_geschichten": "Geschichten",
|
||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
|
||||
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.",
|
||||
"error_journey_at_capacity": "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht.",
|
||||
"error_geschichte_type_mismatch": "Diese Geschichte ist keine Lesereise – Reise-Einträge sind hier nicht erlaubt.",
|
||||
"journey_item_document_deleted": "[Dokument gelöscht]",
|
||||
"geschichten_index_title": "Geschichten",
|
||||
"geschichten_new_button": "Neue Geschichte",
|
||||
"geschichten_filter_all_pill": "Alle",
|
||||
@@ -1040,6 +1042,7 @@
|
||||
"geschichten_published_on": "veröffentlicht am {date}",
|
||||
"geschichten_persons_section": "Personen in dieser Geschichte",
|
||||
"geschichten_documents_section": "Erwähnte Dokumente",
|
||||
"geschichten_document_link_placeholder": "Dokument öffnen",
|
||||
"geschichten_card_heading": "Geschichten",
|
||||
"geschichten_card_write_action": "+ Geschichte schreiben",
|
||||
"geschichten_card_attach_action": "+ Geschichte anhängen",
|
||||
@@ -1156,5 +1159,20 @@
|
||||
"themen_alle": "Alle Themen",
|
||||
"themen_leer": "Noch keine Themen vergeben.",
|
||||
"themen_weitere": "+ {count} weitere",
|
||||
"themen_dokumente": "{count} Dokumente"
|
||||
"themen_dokumente": "{count} Dokumente",
|
||||
"journey_badge_list": "REISE",
|
||||
"journey_badge_detail": "LESEREISE",
|
||||
"journey_selector_question": "Was möchtest du erstellen?",
|
||||
"journey_selector_story_title": "Geschichte",
|
||||
"journey_selector_story_desc": "Eine erzählte Geschichte mit Bildern und Text.",
|
||||
"journey_selector_journey_title": "Lesereise",
|
||||
"journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.",
|
||||
"journey_selector_next_btn": "Weiter",
|
||||
"journey_placeholder_back": "andere Auswahl",
|
||||
"journey_placeholder_heading": "Lesereise-Editor folgt in #753",
|
||||
"journey_item_open_aria": "Brief vom {date} öffnen",
|
||||
"journey_item_open_aria_undated": "Brief öffnen",
|
||||
"journey_empty_state": "Diese Lesereise ist noch leer.",
|
||||
"journey_interlude_aria_label": "Kuratorennotiz",
|
||||
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren."
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
"error_forbidden": "You do not have permission for this action.",
|
||||
"error_csrf_token_missing": "Session error. Please reload the page.",
|
||||
"error_too_many_login_attempts": "Too many login attempts. Please try again later.",
|
||||
"error_smart_search_unavailable": "The smart search is currently unavailable. Please use the regular search.",
|
||||
"error_smart_search_rate_limited": "You have used the search function too frequently. Please wait a minute.",
|
||||
"smart_search_keywords_not_applied": "Keywords could not be applied to this search.",
|
||||
"error_validation_error": "The input is invalid.",
|
||||
"error_internal_error": "An unexpected error occurred.",
|
||||
"nav_documents": "Documents",
|
||||
@@ -1026,6 +1023,11 @@
|
||||
"nav_stammbaum": "Family tree",
|
||||
"nav_geschichten": "Stories",
|
||||
"error_geschichte_not_found": "The story was not found.",
|
||||
"error_journey_item_not_found": "The journey item was not found.",
|
||||
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
|
||||
"error_journey_at_capacity": "The reading journey has already reached the maximum of 100 items.",
|
||||
"error_geschichte_type_mismatch": "This story is not a reading journey — journey items are not allowed here.",
|
||||
"journey_item_document_deleted": "[Document deleted]",
|
||||
"geschichten_index_title": "Stories",
|
||||
"geschichten_new_button": "New story",
|
||||
"geschichten_filter_all_pill": "All",
|
||||
@@ -1040,6 +1042,7 @@
|
||||
"geschichten_published_on": "published on {date}",
|
||||
"geschichten_persons_section": "People in this story",
|
||||
"geschichten_documents_section": "Referenced documents",
|
||||
"geschichten_document_link_placeholder": "Open document",
|
||||
"geschichten_card_heading": "Stories",
|
||||
"geschichten_card_write_action": "+ Write a story",
|
||||
"geschichten_card_attach_action": "+ Attach a story",
|
||||
@@ -1156,5 +1159,20 @@
|
||||
"themen_alle": "All Topics",
|
||||
"themen_leer": "No topics assigned yet.",
|
||||
"themen_weitere": "+ {count} more",
|
||||
"themen_dokumente": "{count} documents"
|
||||
"themen_dokumente": "{count} documents",
|
||||
"journey_badge_list": "JOURNEY",
|
||||
"journey_badge_detail": "READING JOURNEY",
|
||||
"journey_selector_question": "What would you like to create?",
|
||||
"journey_selector_story_title": "Story",
|
||||
"journey_selector_story_desc": "A narrative story with images and text.",
|
||||
"journey_selector_journey_title": "Reading Journey",
|
||||
"journey_selector_journey_desc": "A curated selection of letters with notes.",
|
||||
"journey_selector_next_btn": "Continue",
|
||||
"journey_placeholder_back": "different selection",
|
||||
"journey_placeholder_heading": "Reading Journey editor coming in #753",
|
||||
"journey_item_open_aria": "Open letter from {date}",
|
||||
"journey_item_open_aria_undated": "Open letter",
|
||||
"journey_empty_state": "This reading journey is still empty.",
|
||||
"journey_interlude_aria_label": "Curator's note",
|
||||
"journey_selector_aria_live_hint": "Please select a type to continue."
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
"error_forbidden": "No tiene permiso para realizar esta acción.",
|
||||
"error_csrf_token_missing": "Error de sesión. Recargue la página.",
|
||||
"error_too_many_login_attempts": "Demasiados intentos. Por favor, inténtelo más tarde.",
|
||||
"error_smart_search_unavailable": "La búsqueda inteligente no está disponible en este momento. Por favor, usa la búsqueda normal.",
|
||||
"error_smart_search_rate_limited": "Has utilizado la función de búsqueda demasiadas veces. Por favor, espera un minuto.",
|
||||
"smart_search_keywords_not_applied": "Las palabras clave no pudieron aplicarse a esta búsqueda.",
|
||||
"error_validation_error": "La entrada no es válida.",
|
||||
"error_internal_error": "Se ha producido un error inesperado.",
|
||||
"nav_documents": "Documentos",
|
||||
@@ -1026,6 +1023,11 @@
|
||||
"nav_stammbaum": "Árbol genealógico",
|
||||
"nav_geschichten": "Historias",
|
||||
"error_geschichte_not_found": "No se encontró la historia.",
|
||||
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
|
||||
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
|
||||
"error_journey_at_capacity": "El viaje de lectura ya ha alcanzado el máximo de 100 entradas.",
|
||||
"error_geschichte_type_mismatch": "Esta historia no es un viaje de lectura — los elementos de viaje no están permitidos aquí.",
|
||||
"journey_item_document_deleted": "[Documento eliminado]",
|
||||
"geschichten_index_title": "Historias",
|
||||
"geschichten_new_button": "Nueva historia",
|
||||
"geschichten_filter_all_pill": "Todas",
|
||||
@@ -1040,6 +1042,7 @@
|
||||
"geschichten_published_on": "publicada el {date}",
|
||||
"geschichten_persons_section": "Personas en esta historia",
|
||||
"geschichten_documents_section": "Documentos mencionados",
|
||||
"geschichten_document_link_placeholder": "Abrir documento",
|
||||
"geschichten_card_heading": "Historias",
|
||||
"geschichten_card_write_action": "+ Escribir historia",
|
||||
"geschichten_card_attach_action": "+ Adjuntar historia",
|
||||
@@ -1156,5 +1159,20 @@
|
||||
"themen_alle": "Todos los temas",
|
||||
"themen_leer": "Aún no hay temas.",
|
||||
"themen_weitere": "+ {count} más",
|
||||
"themen_dokumente": "{count} documentos"
|
||||
"themen_dokumente": "{count} documentos",
|
||||
"journey_badge_list": "VIAJE",
|
||||
"journey_badge_detail": "VIAJE DE LECTURA",
|
||||
"journey_selector_question": "¿Qué deseas crear?",
|
||||
"journey_selector_story_title": "Historia",
|
||||
"journey_selector_story_desc": "Una historia narrada con imágenes y texto.",
|
||||
"journey_selector_journey_title": "Viaje de lectura",
|
||||
"journey_selector_journey_desc": "Una selección curada de cartas con notas.",
|
||||
"journey_selector_next_btn": "Continuar",
|
||||
"journey_placeholder_back": "otra selección",
|
||||
"journey_placeholder_heading": "Editor de viaje de lectura próximamente en #753",
|
||||
"journey_item_open_aria": "Abrir carta del {date}",
|
||||
"journey_item_open_aria_undated": "Abrir carta",
|
||||
"journey_empty_state": "Este viaje de lectura está vacío.",
|
||||
"journey_interlude_aria_label": "Nota del curador",
|
||||
"journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar."
|
||||
}
|
||||
|
||||
@@ -52,6 +52,6 @@ describe('DashboardNeedsMetadata', () => {
|
||||
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
|
||||
const docs = [makeDoc('d1', 'Only one')];
|
||||
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
|
||||
await expect.element(page.getByRole('link', { name: /50/ })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('link', { name: /Alle 50/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/geschichten/{id}/items/reorder": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Reorder journey items
|
||||
* @description itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request.
|
||||
*/
|
||||
put: operations["reorderItems"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -228,22 +248,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/search/nl": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["search"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/persons": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -436,6 +440,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/geschichten/{id}/items": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["appendItem"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -708,22 +728,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-titles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["backfillTitles"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/backfill-file-hashes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -820,6 +824,22 @@ export interface paths {
|
||||
patch: operations["update"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/geschichten/{id}/items/{itemId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["deleteItem"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch: operations["updateItemNote"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/training-labels": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -891,7 +911,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["search_1"];
|
||||
get: operations["search"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -1355,7 +1375,7 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["search_2"];
|
||||
get: operations["search_1"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -1444,6 +1464,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/conversation": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getConversation"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/dashboard/resume": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1706,6 +1742,32 @@ export interface components {
|
||||
provisional: boolean;
|
||||
readonly displayName: string;
|
||||
};
|
||||
JourneyReorderDTO: {
|
||||
itemIds?: string[];
|
||||
};
|
||||
DocumentSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
/** Format: date */
|
||||
documentDate?: string;
|
||||
/** Format: date */
|
||||
documentDateEnd?: string;
|
||||
/** @enum {string} */
|
||||
datePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
senderName?: string;
|
||||
receiverName?: string;
|
||||
/** Format: int32 */
|
||||
receiverCount: number;
|
||||
};
|
||||
JourneyItemView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: int32 */
|
||||
position: number;
|
||||
document?: components["schemas"]["DocumentSummary"];
|
||||
note?: string;
|
||||
};
|
||||
DocumentUpdateDTO: {
|
||||
title?: string;
|
||||
/** Format: date */
|
||||
@@ -1774,7 +1836,6 @@ export interface components {
|
||||
sender?: components["schemas"]["Person"];
|
||||
tags?: components["schemas"]["Tag"][];
|
||||
trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[];
|
||||
hasTranscription: boolean;
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
PersonMention: {
|
||||
@@ -1835,98 +1896,6 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
targetId: string;
|
||||
};
|
||||
NlSearchRequest: {
|
||||
query: string;
|
||||
};
|
||||
Pageable: {
|
||||
/** Format: int32 */
|
||||
page?: number;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
sort?: string[];
|
||||
};
|
||||
ActivityActorDTO: {
|
||||
initials: string;
|
||||
color: string;
|
||||
name?: string;
|
||||
};
|
||||
DocumentListItem: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
originalFilename: string;
|
||||
thumbnailUrl?: string;
|
||||
/** Format: date */
|
||||
documentDate?: string;
|
||||
/** @enum {string} */
|
||||
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
/** Format: date */
|
||||
metaDateEnd?: string;
|
||||
sender?: components["schemas"]["Person"];
|
||||
receivers: components["schemas"]["Person"][];
|
||||
tags: components["schemas"]["Tag"][];
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
location?: string;
|
||||
summary?: string;
|
||||
/** Format: int32 */
|
||||
completionPercentage: number;
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
matchData: components["schemas"]["SearchMatchData"];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
DocumentSearchResult: {
|
||||
items: components["schemas"]["DocumentListItem"][];
|
||||
/** Format: int64 */
|
||||
totalElements: number;
|
||||
/** Format: int32 */
|
||||
pageNumber: number;
|
||||
/** Format: int32 */
|
||||
pageSize: number;
|
||||
/** Format: int32 */
|
||||
totalPages: number;
|
||||
/** Format: int64 */
|
||||
undatedCount: number;
|
||||
};
|
||||
MatchOffset: {
|
||||
/** Format: int32 */
|
||||
start: number;
|
||||
/** Format: int32 */
|
||||
length: number;
|
||||
};
|
||||
NlQueryInterpretation: {
|
||||
resolvedPersons: components["schemas"]["PersonHint"][];
|
||||
ambiguousPersons: components["schemas"]["PersonHint"][];
|
||||
/** Format: date */
|
||||
dateFrom?: string;
|
||||
/** Format: date */
|
||||
dateTo?: string;
|
||||
keywords: string[];
|
||||
rawQuery: string;
|
||||
keywordsApplied: boolean;
|
||||
};
|
||||
NlSearchResponse: {
|
||||
result: components["schemas"]["DocumentSearchResult"];
|
||||
interpretation: components["schemas"]["NlQueryInterpretation"];
|
||||
};
|
||||
PersonHint: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
SearchMatchData: {
|
||||
transcriptionSnippet?: string;
|
||||
titleOffsets: components["schemas"]["MatchOffset"][];
|
||||
senderMatched: boolean;
|
||||
matchedReceiverIds: string[];
|
||||
matchedTagIds: string[];
|
||||
snippetOffsets: components["schemas"]["MatchOffset"][];
|
||||
summarySnippet?: string;
|
||||
summaryOffsets: components["schemas"]["MatchOffset"][];
|
||||
};
|
||||
CreateRelationshipRequest: {
|
||||
/** Format: uuid */
|
||||
relatedPersonId: string;
|
||||
@@ -2272,6 +2241,9 @@ export interface components {
|
||||
actorName?: string;
|
||||
documentTitle?: string;
|
||||
};
|
||||
JourneyItemUpdateDTO: {
|
||||
note?: string;
|
||||
};
|
||||
TrainingLabelRequest: {
|
||||
label?: string;
|
||||
enrolled?: boolean;
|
||||
@@ -2312,6 +2284,11 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
transcriptionCount: number;
|
||||
};
|
||||
ActivityActorDTO: {
|
||||
initials: string;
|
||||
color: string;
|
||||
name?: string;
|
||||
};
|
||||
TranscriptionQueueItemDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -2334,11 +2311,6 @@ export interface components {
|
||||
color?: string;
|
||||
/** Format: int32 */
|
||||
documentCount: number;
|
||||
/**
|
||||
* Format: int32
|
||||
* @description Distinct documents tagged with this tag or any descendant tag (subtree rollup)
|
||||
*/
|
||||
subtreeDocumentCount: number;
|
||||
children?: components["schemas"]["TagTreeNodeDTO"][];
|
||||
/**
|
||||
* Format: uuid
|
||||
@@ -2374,13 +2346,13 @@ export interface components {
|
||||
lastName?: string;
|
||||
/** Format: int64 */
|
||||
documentCount?: number;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
provisional?: boolean;
|
||||
alias?: string;
|
||||
personType?: string;
|
||||
familyMember?: boolean;
|
||||
};
|
||||
@@ -2479,6 +2451,8 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
content?: components["schemas"]["NotificationDTO"][];
|
||||
@@ -2487,8 +2461,6 @@ export interface components {
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
empty?: boolean;
|
||||
};
|
||||
PageableObject: {
|
||||
@@ -2511,6 +2483,54 @@ export interface components {
|
||||
nodes: components["schemas"]["PersonNodeDTO"][];
|
||||
edges: components["schemas"]["RelationshipDTO"][];
|
||||
};
|
||||
AuthorSummary: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email: string;
|
||||
};
|
||||
GeschichteSummary: {
|
||||
body?: string;
|
||||
title: string;
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
type: "STORY" | "JOURNEY";
|
||||
/** @enum {string} */
|
||||
status: "DRAFT" | "PUBLISHED";
|
||||
author?: components["schemas"]["AuthorSummary"];
|
||||
/** Format: date-time */
|
||||
publishedAt?: string;
|
||||
};
|
||||
AuthorView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
GeschichteView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
/** @enum {string} */
|
||||
status: "DRAFT" | "PUBLISHED";
|
||||
/** @enum {string} */
|
||||
type: "STORY" | "JOURNEY";
|
||||
author?: components["schemas"]["AuthorView"];
|
||||
persons: components["schemas"]["PersonView"][];
|
||||
items: components["schemas"]["JourneyItemView"][];
|
||||
/** Format: date-time */
|
||||
publishedAt?: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
PersonView: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
DocumentVersionSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -2552,6 +2572,63 @@ export interface components {
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
};
|
||||
DocumentListItem: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
originalFilename: string;
|
||||
thumbnailUrl?: string;
|
||||
/** Format: date */
|
||||
documentDate?: string;
|
||||
/** @enum {string} */
|
||||
metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
/** Format: date */
|
||||
metaDateEnd?: string;
|
||||
sender?: components["schemas"]["Person"];
|
||||
receivers: components["schemas"]["Person"][];
|
||||
tags: components["schemas"]["Tag"][];
|
||||
archiveBox?: string;
|
||||
archiveFolder?: string;
|
||||
location?: string;
|
||||
summary?: string;
|
||||
/** Format: int32 */
|
||||
completionPercentage: number;
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
matchData: components["schemas"]["SearchMatchData"];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string;
|
||||
};
|
||||
DocumentSearchResult: {
|
||||
items: components["schemas"]["DocumentListItem"][];
|
||||
/** Format: int64 */
|
||||
totalElements: number;
|
||||
/** Format: int32 */
|
||||
pageNumber: number;
|
||||
/** Format: int32 */
|
||||
pageSize: number;
|
||||
/** Format: int32 */
|
||||
totalPages: number;
|
||||
/** Format: int64 */
|
||||
undatedCount: number;
|
||||
};
|
||||
MatchOffset: {
|
||||
/** Format: int32 */
|
||||
start: number;
|
||||
/** Format: int32 */
|
||||
length: number;
|
||||
};
|
||||
SearchMatchData: {
|
||||
transcriptionSnippet?: string;
|
||||
titleOffsets: components["schemas"]["MatchOffset"][];
|
||||
senderMatched: boolean;
|
||||
matchedReceiverIds: string[];
|
||||
matchedTagIds: string[];
|
||||
snippetOffsets: components["schemas"]["MatchOffset"][];
|
||||
summarySnippet?: string;
|
||||
summaryOffsets: components["schemas"]["MatchOffset"][];
|
||||
};
|
||||
IncompleteDocumentDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -2600,7 +2677,7 @@ export interface components {
|
||||
};
|
||||
ActivityFeedItemDTO: {
|
||||
/** @enum {string} */
|
||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED";
|
||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED";
|
||||
actor?: components["schemas"]["ActivityActorDTO"];
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
@@ -2910,6 +2987,32 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
reorderItems: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["JourneyReorderDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["JourneyItemView"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocument: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3236,32 +3339,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
search: {
|
||||
parameters: {
|
||||
query: {
|
||||
pageable: components["schemas"]["Pageable"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["NlSearchRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["NlSearchResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getPersons: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -3685,6 +3762,32 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
appendItem: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["JourneyItemCreateDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["JourneyItemView"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createDocument: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4183,26 +4286,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillTitles: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["BackfillResult"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
backfillFileHashes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4356,7 +4439,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Geschichte"];
|
||||
"*/*": components["schemas"]["GeschichteView"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -4407,6 +4490,54 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteItem: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
itemId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
updateItemNote: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
itemId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["JourneyItemUpdateDTO"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["JourneyItemView"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
patchTrainingLabel: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4551,7 +4682,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
search_1: {
|
||||
search: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
@@ -5175,7 +5306,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
search_2: {
|
||||
search_1: {
|
||||
parameters: {
|
||||
query?: {
|
||||
q?: string;
|
||||
@@ -5345,6 +5476,32 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getConversation: {
|
||||
parameters: {
|
||||
query: {
|
||||
senderId: string;
|
||||
receiverId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
dir?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getResume: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5390,7 +5547,7 @@ export interface operations {
|
||||
query?: {
|
||||
limit?: number;
|
||||
/** @description Filter by audit kinds; omit for all rollup-eligible kinds */
|
||||
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[];
|
||||
kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -6,33 +6,23 @@ import StarterKit from '@tiptap/starter-kit';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte';
|
||||
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type Person = components['schemas']['Person'];
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
interface Props {
|
||||
geschichte?: Geschichte | null;
|
||||
initialPersons?: Person[];
|
||||
initialDocuments?: Document[];
|
||||
onSubmit: (payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
documentIds: string[];
|
||||
}) => Promise<void>;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
geschichte = null,
|
||||
initialPersons = [],
|
||||
initialDocuments = [],
|
||||
onSubmit,
|
||||
submitting = false
|
||||
}: Props = $props();
|
||||
let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Props = $props();
|
||||
|
||||
// Initial-state snapshot from incoming props. The editor owns these values
|
||||
// after mount; the parent should re-mount the component with a different
|
||||
@@ -44,9 +34,6 @@ let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
|
||||
let selectedPersons: Person[] = $state(
|
||||
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
|
||||
);
|
||||
let selectedDocuments: Document[] = $state(
|
||||
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
|
||||
);
|
||||
|
||||
let dirty = $state(false);
|
||||
let titleTouched = $state(false);
|
||||
@@ -122,8 +109,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
title: title.trim(),
|
||||
body,
|
||||
status: nextStatus,
|
||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
|
||||
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
|
||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean)
|
||||
});
|
||||
dirty = false;
|
||||
}
|
||||
@@ -269,14 +255,6 @@ function exec(action: () => void) {
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
||||
</section>
|
||||
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichte_editor_dokumente_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
|
||||
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,19 +8,9 @@ const personFactory = (id: string, displayName: string) => ({
|
||||
firstName: displayName.split(' ')[0],
|
||||
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
|
||||
displayName,
|
||||
personType: 'PERSON' as const
|
||||
});
|
||||
|
||||
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: date,
|
||||
originalFilename: `${title}.pdf`,
|
||||
status: 'UPLOADED' as const,
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN' as const,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
personType: 'PERSON' as const,
|
||||
familyMember: false,
|
||||
provisional: false
|
||||
});
|
||||
|
||||
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
||||
@@ -28,8 +18,9 @@ const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
||||
title: 'Existing draft',
|
||||
body: '<p>Hello world</p>',
|
||||
status: 'DRAFT' as const,
|
||||
type: 'STORY' as const,
|
||||
persons: [],
|
||||
documents: [],
|
||||
items: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
...overrides
|
||||
@@ -93,14 +84,6 @@ describe('GeschichteEditor — pre-fill', () => {
|
||||
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders initial documents as chips', async () => {
|
||||
render(GeschichteEditor, {
|
||||
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates the title input from a geschichte prop', async () => {
|
||||
render(GeschichteEditor, {
|
||||
geschichte: draftFactory({ title: 'My existing story' }),
|
||||
@@ -154,11 +137,10 @@ describe('GeschichteEditor — onSubmit payload', () => {
|
||||
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
|
||||
});
|
||||
|
||||
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
|
||||
it('passes personIds from initial props through onSubmit', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, {
|
||||
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
||||
initialDocuments: [docFactory('d1', 'Brief A')],
|
||||
onSubmit
|
||||
});
|
||||
|
||||
@@ -171,6 +153,5 @@ describe('GeschichteEditor — onSubmit payload', () => {
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.personIds).toEqual(['p1']);
|
||||
expect(payload.documentIds).toEqual(['d1']);
|
||||
});
|
||||
});
|
||||
|
||||
42
frontend/src/lib/geschichte/GeschichteListRow.svelte
Normal file
42
frontend/src/lib/geschichte/GeschichteListRow.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||
import { formatAuthorName, formatPublishedAt } from './utils';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type GeschichteRow = Pick<
|
||||
components['schemas']['GeschichteSummary'],
|
||||
'id' | 'title' | 'body' | 'type' | 'author' | 'publishedAt'
|
||||
>;
|
||||
|
||||
let { geschichte }: { geschichte: GeschichteRow } = $props();
|
||||
|
||||
const isJourney = $derived(geschichte.type === 'JOURNEY');
|
||||
|
||||
const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short'));
|
||||
|
||||
const authorName = $derived(formatAuthorName(geschichte.author));
|
||||
</script>
|
||||
|
||||
<a href="/geschichten/{geschichte.id}" class="block">
|
||||
<div class="mb-1 flex items-center gap-1.5">
|
||||
<h2 class="font-serif text-xl font-bold text-ink">{geschichte.title}</h2>
|
||||
{#if isJourney}
|
||||
<span
|
||||
data-testid="journey-badge"
|
||||
style="font-size: 0.75rem"
|
||||
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase"
|
||||
>
|
||||
{m.journey_badge_list()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">
|
||||
{authorName}
|
||||
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
||||
</p>
|
||||
{#if geschichte.body}
|
||||
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
||||
<p class="font-serif text-base text-ink-2">{plainExcerpt(geschichte.body, 150)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
65
frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts
Normal file
65
frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
const { default: GeschichteListRow } = await import('./GeschichteListRow.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const baseRow = (overrides = {}) => ({
|
||||
id: 'g1',
|
||||
title: 'Die Reise nach Berlin',
|
||||
body: '<p>Im Jahr 1923...</p>',
|
||||
type: 'STORY' as 'STORY' | 'JOURNEY',
|
||||
status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT',
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' },
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('GeschichteListRow', () => {
|
||||
it('renders the title', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||
await expect
|
||||
.element(page.getByRole('heading', { level: 2 }))
|
||||
.toHaveTextContent('Die Reise nach Berlin');
|
||||
});
|
||||
|
||||
it('shows no badge for STORY type', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } });
|
||||
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no badge when type is undefined', async () => {
|
||||
render(GeschichteListRow, {
|
||||
props: { geschichte: baseRow({ type: undefined as unknown as 'STORY' | 'JOURNEY' }) }
|
||||
});
|
||||
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows REISE badge for JOURNEY type', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.textContent?.trim()).toBe('REISE');
|
||||
});
|
||||
|
||||
it('badge is a plain <span>, not a nested interactive element', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||
expect(badge?.tagName.toLowerCase()).toBe('span');
|
||||
});
|
||||
|
||||
it('badge has small font size appropriate for a label', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } });
|
||||
const badge = document.querySelector('[data-testid="journey-badge"]');
|
||||
const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize);
|
||||
expect(fontSize).toBeGreaterThan(0);
|
||||
expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size
|
||||
});
|
||||
|
||||
it('renders author name in meta line', async () => {
|
||||
render(GeschichteListRow, { props: { geschichte: baseRow() } });
|
||||
expect(document.body.textContent).toContain('Anna Schmidt');
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,10 @@ import type { components } from '$lib/generated/api';
|
||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
|
||||
interface Props {
|
||||
geschichten: Geschichte[];
|
||||
geschichten: GeschichteSummary[];
|
||||
personId: string;
|
||||
personName: string;
|
||||
canWrite: boolean;
|
||||
@@ -18,12 +18,12 @@ let { geschichten, personId, personName, canWrite }: Props = $props();
|
||||
const visible = $derived(geschichten.slice(0, 3));
|
||||
const hasOverflow = $derived(geschichten.length >= 3);
|
||||
|
||||
function formatPublishedDate(g: Geschichte): string | null {
|
||||
function formatPublishedDate(g: GeschichteSummary): string | null {
|
||||
if (!g.publishedAt) return null;
|
||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||
}
|
||||
|
||||
function authorName(g: Geschichte): string {
|
||||
function authorName(g: GeschichteSummary): string {
|
||||
const a = g.author;
|
||||
if (!a) return '';
|
||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||
|
||||
@@ -3,16 +3,17 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import GeschichtenCard from './GeschichtenCard.svelte';
|
||||
|
||||
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
|
||||
const makeStory = (id: string, title: string, body: string | undefined = '<p>Body</p>') => ({
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
status: 'PUBLISHED' as const,
|
||||
type: 'STORY' as const,
|
||||
publishedAt: '2024-04-01T12:00:00',
|
||||
createdAt: '2024-03-01T12:00:00',
|
||||
updatedAt: '2024-04-01T12:00:00',
|
||||
persons: [],
|
||||
documents: [],
|
||||
items: [],
|
||||
author: {
|
||||
id: 'u1',
|
||||
email: 'marcel@example.com',
|
||||
@@ -120,6 +121,16 @@ describe('GeschichtenCard', () => {
|
||||
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
||||
});
|
||||
|
||||
it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a plain-text excerpt without HTML markup', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [
|
||||
|
||||
@@ -2,20 +2,30 @@ import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import GeschichtenCard from './GeschichtenCard.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type GeschichteSummary = components['schemas']['GeschichteSummary'];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'g1',
|
||||
title: 'Reise nach Berlin',
|
||||
body: '<p>Brief text</p>',
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@b' } as unknown,
|
||||
...overrides
|
||||
});
|
||||
const makeGeschichte = (overrides: Record<string, unknown> = {}): GeschichteSummary =>
|
||||
({
|
||||
id: 'g1',
|
||||
title: 'Reise nach Berlin',
|
||||
body: '<p>Brief text</p>',
|
||||
status: 'PUBLISHED' as const,
|
||||
type: 'STORY' as const,
|
||||
publishedAt: '2026-04-15T10:00:00Z',
|
||||
author: {
|
||||
email: 'a@b',
|
||||
firstName: 'Anna',
|
||||
lastName: 'Schmidt'
|
||||
},
|
||||
...overrides
|
||||
}) as GeschichteSummary;
|
||||
|
||||
const baseProps = (overrides: Record<string, unknown> = {}) => ({
|
||||
geschichten: [] as ReturnType<typeof makeGeschichte>[],
|
||||
geschichten: [] as GeschichteSummary[],
|
||||
personId: 'p-1',
|
||||
personName: 'Anna Schmidt',
|
||||
canWrite: false,
|
||||
|
||||
24
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
24
frontend/src/lib/geschichte/JourneyInterlude.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
note: string;
|
||||
}
|
||||
|
||||
let { note }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="note"
|
||||
aria-label={m.journey_interlude_aria_label()}
|
||||
class="my-2 border-l-4 border-journey-border bg-journey-tint px-4 py-3"
|
||||
>
|
||||
<p
|
||||
class="text-center font-sans text-xs tracking-widest text-journey uppercase"
|
||||
aria-hidden="true"
|
||||
>
|
||||
❦
|
||||
</p>
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="font-serif text-base leading-relaxed text-ink-2 italic">{note}</p>
|
||||
</div>
|
||||
53
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
53
frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
const { default: JourneyInterlude } = await import('./JourneyInterlude.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__xss_interlude?: number;
|
||||
}
|
||||
}
|
||||
|
||||
describe('JourneyInterlude', () => {
|
||||
it('renders the note text as plaintext', async () => {
|
||||
render(JourneyInterlude, { props: { note: 'Eine kurze Pause auf der Reise.' } });
|
||||
|
||||
await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible();
|
||||
});
|
||||
|
||||
it('has aria-label from i18n (journey_interlude_aria_label)', async () => {
|
||||
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||
|
||||
const el = document.querySelector(`[aria-label="${m.journey_interlude_aria_label()}"]`);
|
||||
expect(el).not.toBeNull();
|
||||
});
|
||||
|
||||
it('has role="note" so the aria-label is announced by screen readers', async () => {
|
||||
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||
|
||||
const el = document.querySelector('[role="note"]');
|
||||
expect(el).not.toBeNull();
|
||||
expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label());
|
||||
});
|
||||
|
||||
it('renders the section-break glyph ❦', async () => {
|
||||
render(JourneyInterlude, { props: { note: 'Notiz' } });
|
||||
|
||||
expect(document.body.textContent).toContain('❦');
|
||||
});
|
||||
|
||||
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||
// Interlude uses Svelte text interpolation ({note}), NOT {@html}.
|
||||
render(JourneyInterlude, {
|
||||
props: { note: '<img src=x onerror="window.__xss_interlude=1">' }
|
||||
});
|
||||
|
||||
expect(window.__xss_interlude).toBeUndefined();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||
});
|
||||
});
|
||||
43
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
43
frontend/src/lib/geschichte/JourneyItemCard.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
item: JourneyItemView;
|
||||
}
|
||||
|
||||
let { item }: Props = $props();
|
||||
|
||||
// Safe: JourneyReader filters out items where document === null before rendering this component.
|
||||
const doc = $derived(item.document!);
|
||||
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||
const ariaLabel = $derived(
|
||||
formattedDate
|
||||
? m.journey_item_open_aria({ date: formattedDate })
|
||||
: m.journey_item_open_aria_undated()
|
||||
);
|
||||
const hasNote = $derived(item.note != null && item.note.trim().length > 0);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
aria-label={ariaLabel}
|
||||
style="display: flex; min-height: 44px; flex-direction: column"
|
||||
class="flex min-h-[44px] flex-col gap-1 rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<span class="font-bold">{doc.title}</span>
|
||||
{#if formattedDate}
|
||||
<span class="font-sans text-sm text-ink-3">{formattedDate}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if hasNote}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mt-1 flex items-baseline gap-1 font-sans text-sm text-ink-3">
|
||||
<span aria-hidden="true">✎</span>
|
||||
{item.note}
|
||||
</p>
|
||||
{/if}
|
||||
124
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
124
frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__xss_note?: number;
|
||||
}
|
||||
}
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
const baseItem = (overrides: Partial<JourneyItemView> = {}): JourneyItemView => ({
|
||||
id: 'item1',
|
||||
position: 0,
|
||||
document: {
|
||||
id: 'd1',
|
||||
title: 'Brief an Helene',
|
||||
documentDate: '1923-05-15',
|
||||
datePrecision: 'FULL'
|
||||
},
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('JourneyItemCard', () => {
|
||||
it('renders the document title', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
await expect.element(page.getByText('Brief an Helene')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders the document date when documentDate is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
await expect.element(page.getByText(/1923/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('whole card is a single <a> element', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.href).toContain('/documents/d1');
|
||||
});
|
||||
|
||||
it('link has dated aria-label when documentDate is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link?.getAttribute('aria-label')).toContain('Brief');
|
||||
expect(link?.getAttribute('aria-label')).toContain('1923');
|
||||
});
|
||||
|
||||
it('link has undated aria-label when documentDate is absent', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const link = document.querySelector('a');
|
||||
expect(link?.getAttribute('aria-label')).toBe('Brief öffnen');
|
||||
});
|
||||
|
||||
it('omits date text when documentDate is absent', async () => {
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' }
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(/1923/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ✎ glyph and note text when note is present', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } });
|
||||
|
||||
expect(document.body.textContent).toContain('✎');
|
||||
await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible();
|
||||
});
|
||||
|
||||
it('omits annotation block when note is blank or whitespace', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } });
|
||||
|
||||
expect(document.body.textContent).not.toContain('✎');
|
||||
});
|
||||
|
||||
it('omits annotation block when note is absent', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } });
|
||||
|
||||
expect(document.body.textContent).not.toContain('✎');
|
||||
});
|
||||
|
||||
it('link meets 44px touch-target minimum height', async () => {
|
||||
render(JourneyItemCard, { props: { item: baseItem() } });
|
||||
|
||||
const link = document.querySelector('a');
|
||||
const rect = link?.getBoundingClientRect();
|
||||
expect(rect?.height).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
|
||||
it('XSS: note is rendered as plaintext — injected payload does not execute', async () => {
|
||||
// Note uses Svelte text interpolation ({note}), NOT {@html}.
|
||||
render(JourneyItemCard, {
|
||||
props: {
|
||||
item: baseItem({
|
||||
note: '<img src=x onerror="window.__xss_note=1">'
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(window.__xss_note).toBeUndefined();
|
||||
expect(document.body.textContent).toContain('<img src=x onerror=');
|
||||
});
|
||||
});
|
||||
70
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
70
frontend/src/lib/geschichte/JourneyReader.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import JourneyItemCard from './JourneyItemCard.svelte';
|
||||
import JourneyInterlude from './JourneyInterlude.svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type GeschichteView = components['schemas']['GeschichteView'];
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
geschichte: GeschichteView;
|
||||
canBlogWrite: boolean;
|
||||
ondelete?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let { geschichte: g, canBlogWrite, ondelete }: Props = $props();
|
||||
|
||||
// Render intro only when body is a non-empty, non-whitespace string.
|
||||
const introText = $derived(g.body?.trim() ? g.body : null);
|
||||
|
||||
// Omit items that have neither a document nor a non-blank note (dangling deleted-document guard).
|
||||
const validItems = $derived(
|
||||
g.items.filter(
|
||||
(item: JourneyItemView) =>
|
||||
item.document != null || (item.note != null && item.note.trim().length > 0)
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if introText}
|
||||
<!-- plaintext — do NOT use {@html} here -->
|
||||
<p class="mb-8 font-serif text-base leading-relaxed text-ink-2 italic">{introText}</p>
|
||||
{/if}
|
||||
|
||||
{#if validItems.length === 0}
|
||||
<p class="font-sans text-sm text-ink-3" data-testid="journey-empty-state">
|
||||
{m.journey_empty_state()}
|
||||
</p>
|
||||
{:else}
|
||||
<ol class="flex list-none flex-col gap-4">
|
||||
{#each validItems as item (item.id)}
|
||||
<li>
|
||||
{#if item.document != null}
|
||||
<JourneyItemCard item={item} />
|
||||
{:else}
|
||||
<JourneyInterlude note={item.note!} />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
<!-- Author actions -->
|
||||
{#if canBlogWrite}
|
||||
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
|
||||
<a
|
||||
href="/geschichten/{g.id}/edit"
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => ondelete?.()}
|
||||
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user