Compare commits
35 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde1237358 | ||
|
|
788a804810 | ||
|
|
62b96f718f | ||
|
|
6ed5151e50 | ||
|
|
3a7c86fc87 | ||
|
|
1226bd0a07 | ||
|
|
00a00b2c87 | ||
|
|
cc841a7a4c | ||
|
|
513cdb7a4d | ||
|
|
595007213c | ||
|
|
45001f042a | ||
|
|
d11378c254 | ||
|
|
f64acbc697 | ||
|
|
75e48f2922 | ||
|
|
ad344db2bf | ||
|
|
3626cd1a6d | ||
|
|
fe4e2d97d0 | ||
|
|
e712477d2b | ||
|
|
4419c434a1 | ||
|
|
687353a819 | ||
|
|
e4e277219e | ||
|
|
a75c46351f | ||
|
|
65a34d48b4 | ||
|
|
0e7095fee6 | ||
|
|
adac1b1f99 | ||
|
|
29ada9b681 | ||
|
|
92a2feba1e | ||
|
|
ba7e8ca6f5 | ||
|
|
f408f60631 | ||
| 38a6d6b0fc | |||
| b33d0eb850 | |||
|
|
4bcf568ed4 | ||
|
|
ddb1ec4df8 | ||
|
|
e63eaadc33 | ||
|
|
d4a25e34d8 |
27
CLAUDE.md
27
CLAUDE.md
@@ -86,7 +86,8 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── transcription/ TranscriptionBlock, TranscriptionService, TranscriptionBlockQueryService
|
||||
├── exception/ DomainException, ErrorCode, GlobalExceptionHandler
|
||||
├── filestorage/ FileService (S3/MinIO)
|
||||
├── geschichte/ Geschichte (story) domain
|
||||
├── geschichte/ Geschichte (story) domain — GeschichteService, GeschichteQueryService
|
||||
│ └── journeyitem/ JourneyItem sub-domain — JourneyItemService, JourneyItemController
|
||||
├── importing/ CanonicalImportOrchestrator + four loaders (TagTree/PersonRegister/PersonTree/Document) + CanonicalSheetReader
|
||||
├── notification/ Notification domain + SseEmitterRegistry
|
||||
├── ocr/ OCR domain — OcrService, OcrBatchService, training
|
||||
@@ -94,6 +95,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── relationship/ PersonRelationship sub-domain
|
||||
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||
├── tag/ Tag domain
|
||||
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
|
||||
└── user/ User domain — AppUser, UserGroup, UserService
|
||||
```
|
||||
|
||||
@@ -105,13 +107,16 @@ 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` |
|
||||
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
@@ -152,7 +157,7 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
|
||||
|
||||
### DTOs
|
||||
|
||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
|
||||
Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs) — **except the geschichte domain**, where every response is a view (`GeschichteView`/`GeschichteSummary`/`JourneyItemView`) assembled inside the service transaction and entities never cross the controller boundary. See [ADR-036](./docs/adr/036-geschichte-responses-are-views-not-entities.md) — lazy collections + `open-in-view: false` make serialized entities a 500 waiting to happen.
|
||||
|
||||
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
|
||||
|
||||
@@ -160,7 +165,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).
|
||||
**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||
|
||||
### Security / Permissions
|
||||
|
||||
@@ -268,7 +273,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).
|
||||
**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG` (journey/geschichte domain constraints).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -41,6 +42,7 @@ src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── relationship/ # PersonRelationship sub-domain
|
||||
├── security/ # SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||
├── tag/ # Tag domain — Tag, TagService, TagController
|
||||
├── timeline/ # Timeline (Zeitstrahl) domain — TimelineEvent, EventType, TimelineEventRepository
|
||||
└── user/ # User domain — AppUser, UserGroup, UserService
|
||||
```
|
||||
|
||||
@@ -66,6 +68,7 @@ For per-domain ownership and public surface, see each domain's `README.md`.
|
||||
| `Comment` | `document_comments` | Threaded comments with mentions |
|
||||
| `Notification` | `notifications` | User notification feed |
|
||||
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
|
||||
| `TimelineEvent` | `timeline_events` | Curated Zeitstrahl event; ManyToMany persons + documents (join FKs ON DELETE CASCADE); `@Version` + NOT NULL createdBy/updatedBy |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
|
||||
@@ -50,10 +50,30 @@ public enum AuditKind {
|
||||
ADMIN_FORCE_LOGOUT,
|
||||
|
||||
/** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */
|
||||
LOGIN_RATE_LIMITED;
|
||||
LOGIN_RATE_LIMITED,
|
||||
|
||||
// --- Documents ---
|
||||
|
||||
/** Payload: none — the deleted document's id is carried in the documentId column */
|
||||
DOCUMENT_DELETED,
|
||||
|
||||
// --- Reading Journeys (Lesereisen) ---
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */
|
||||
JOURNEY_ITEM_ADDED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_REMOVED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */
|
||||
JOURNEY_ITEM_NOTE_UPDATED,
|
||||
|
||||
/** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */
|
||||
JOURNEY_ITEMS_REORDERED;
|
||||
|
||||
public static final Set<AuditKind> ROLLUP_ELIGIBLE = Set.of(
|
||||
TEXT_SAVED, FILE_UPLOADED, ANNOTATION_CREATED,
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED
|
||||
BLOCK_REVIEWED, COMMENT_ADDED, MENTION_CREATED,
|
||||
JOURNEY_ITEMS_REORDERED
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,8 +168,8 @@ public class DocumentController {
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id) {
|
||||
documentService.deleteDocument(id);
|
||||
public ResponseEntity<Void> deleteDocument(@PathVariable UUID id, Authentication authentication) {
|
||||
documentService.deleteDocument(id, requireUserId(authentication));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.raddatz.familienarchiv.document;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Published by DocumentService.deleteDocument inside its @Transactional boundary,
|
||||
* before documentRepository.deleteById fires. Listeners run synchronously in the
|
||||
* publisher's thread and transaction via plain @EventListener — this is load-bearing:
|
||||
* see ADR-038.
|
||||
*/
|
||||
public record DocumentDeletingEvent(UUID documentId) {}
|
||||
@@ -36,6 +36,13 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
@EntityGraph("Document.list")
|
||||
Page<Document> findAll(Pageable pageable);
|
||||
|
||||
// Loader for the relevance fast path: list-item enrichment reads tags after the
|
||||
// repository call returns, so the fetch shape must match the spec-based findAll
|
||||
// overloads above. Plain findAllById carries no entity graph and must not feed
|
||||
// enrichItems — see DocumentService.relevanceSortedPageFromSql.
|
||||
@EntityGraph("Document.list")
|
||||
List<Document> findByIdIn(Collection<UUID> ids);
|
||||
|
||||
// Findet ein Dokument anhand des ursprünglichen Dateinamens
|
||||
// Wichtig für den Abgleich beim Excel-Import & Datei-Upload
|
||||
Optional<Document> findByOriginalFilename(String originalFilename);
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.raddatz.familienarchiv.ocr.TrainingLabel;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.tag.Tag;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -80,6 +81,7 @@ public class DocumentService {
|
||||
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
private final AuditLogQueryService auditLogQueryService;
|
||||
private final ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public record StoreResult(Document document, boolean isNew) {}
|
||||
|
||||
@@ -851,14 +853,14 @@ public class DocumentService {
|
||||
FtsPage ftsPage = toFtsPage(documentRepository.findFtsPageRaw(text, offset, limit));
|
||||
if (ftsPage.hits().isEmpty()) return DocumentSearchResult.of(List.of());
|
||||
|
||||
// Preserve ts_rank order from SQL across the JPA findAllById call.
|
||||
// Preserve ts_rank order from SQL across the JPA findByIdIn call.
|
||||
Map<UUID, Integer> rankMap = new HashMap<>();
|
||||
List<UUID> pageIds = new ArrayList<>();
|
||||
for (int i = 0; i < ftsPage.hits().size(); i++) {
|
||||
rankMap.put(ftsPage.hits().get(i).id(), i);
|
||||
pageIds.add(ftsPage.hits().get(i).id());
|
||||
}
|
||||
List<Document> docs = documentRepository.findAllById(pageIds).stream()
|
||||
List<Document> docs = documentRepository.findByIdIn(pageIds).stream()
|
||||
.sorted(Comparator.comparingInt(d -> rankMap.getOrDefault(d.getId(), Integer.MAX_VALUE)))
|
||||
.toList();
|
||||
return buildResultPaged(docs, text, pageable, ftsPage.total());
|
||||
@@ -1006,6 +1008,28 @@ public class DocumentService {
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight summary lookup for internal use (e.g. journey item append validation).
|
||||
*
|
||||
* <p><strong>Security contract — read before calling:</strong>
|
||||
* <ol>
|
||||
* <li>This method intentionally bypasses per-document scope checks and
|
||||
* tag-colour resolution. It must only be invoked after
|
||||
* {@code @RequirePermission(BLOG_WRITE)} has already been enforced at
|
||||
* the controller layer, guaranteeing the caller is an authenticated
|
||||
* author.</li>
|
||||
* <li>In {@code JourneyItemService.append()}, it is additionally guarded by the
|
||||
* JOURNEY-type check that fires before this call — so the method is never
|
||||
* reached for STORY-type Geschichten.</li>
|
||||
* </ol>
|
||||
* Under the current single-tenant model every authenticated author shares the
|
||||
* same document scope, so skipping per-document scope checks is safe.
|
||||
*/
|
||||
public Document findSummaryByIdInternal(UUID id) {
|
||||
return documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a document for the detail view, additionally flagging whether it has any
|
||||
* transcription to read. Kept separate from {@link #getDocumentById} so the cheap
|
||||
@@ -1075,11 +1099,13 @@ public class DocumentService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteDocument(UUID id) {
|
||||
public void deleteDocument(UUID id, UUID actorId) {
|
||||
if (!documentRepository.existsById(id)) {
|
||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id);
|
||||
}
|
||||
eventPublisher.publishEvent(new DocumentDeletingEvent(id));
|
||||
documentRepository.deleteById(id);
|
||||
auditService.logAfterCommit(AuditKind.DOCUMENT_DELETED, actorId, id, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -15,6 +15,10 @@ public enum ErrorCode {
|
||||
ALIAS_NOT_FOUND,
|
||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||
INVALID_PERSON_TYPE,
|
||||
/** A person's birth date is after their death date. 400 */
|
||||
BIRTH_AFTER_DEATH,
|
||||
/** A life date and its precision are incoherent: date present with UNKNOWN precision, or precision set without a date. 400 */
|
||||
INVALID_DATE_PRECISION,
|
||||
// --- Documents ---
|
||||
/** A document with the given ID does not exist. 404 */
|
||||
DOCUMENT_NOT_FOUND,
|
||||
@@ -122,6 +126,22 @@ public enum ErrorCode {
|
||||
// --- Geschichten (Stories) ---
|
||||
/** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */
|
||||
GESCHICHTE_NOT_FOUND,
|
||||
/** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */
|
||||
JOURNEY_ITEM_NOT_FOUND,
|
||||
/** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */
|
||||
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||
JOURNEY_AT_CAPACITY,
|
||||
/** The document is already present in this journey — duplicate items are not allowed. 409 */
|
||||
JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
/** The type of an existing Geschichte cannot be changed via PATCH. 409 */
|
||||
GESCHICHTE_TYPE_IMMUTABLE,
|
||||
/** A journey-item note exceeds the maximum length (2000 characters). 400 */
|
||||
JOURNEY_NOTE_TOO_LONG,
|
||||
/** A Geschichte title exceeds the maximum length (255 characters — the DB column bound). 400 */
|
||||
GESCHICHTE_TITLE_TOO_LONG,
|
||||
/** A JOURNEY intro (body) exceeds the maximum length (4000 characters). 400 */
|
||||
GESCHICHTE_INTRO_TOO_LONG,
|
||||
|
||||
// --- Tags ---
|
||||
/** A tag with the given ID does not exist. 404 */
|
||||
|
||||
@@ -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,15 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
@@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -28,12 +32,17 @@ import java.util.UUID;
|
||||
public class GeschichteController {
|
||||
|
||||
private final GeschichteService geschichteService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
@GetMapping
|
||||
public List<Geschichte> list(
|
||||
public List<GeschichteSummary> list(
|
||||
@Parameter(description = "Filter by status. Callers without BLOG_WRITE always receive PUBLISHED results regardless of the value passed. Callers with BLOG_WRITE requesting DRAFT receive only their own unpublished stories.")
|
||||
@RequestParam(required = false) GeschichteStatus status,
|
||||
@Parameter(description = "AND-filter: story must include all supplied person IDs.")
|
||||
@RequestParam(name = "personId", required = false) List<UUID> personIds,
|
||||
@Parameter(description = "Filter to stories containing this document.")
|
||||
@RequestParam(required = false) UUID documentId,
|
||||
@Parameter(description = "Maximum results to return. Values ≤ 0 default to 50. Clamped at 200.")
|
||||
@RequestParam(required = false, defaultValue = "50") int limit) {
|
||||
return geschichteService.list(
|
||||
status,
|
||||
@@ -43,20 +52,20 @@ public class GeschichteController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Geschichte getById(@PathVariable UUID id) {
|
||||
return geschichteService.getById(id);
|
||||
public GeschichteView getById(@PathVariable UUID id) {
|
||||
return geschichteService.getView(id);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Geschichte> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
public ResponseEntity<GeschichteView> create(@RequestBody GeschichteUpdateDTO dto) {
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public Geschichte update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
public GeschichteView update(@PathVariable UUID id, @RequestBody GeschichteUpdateDTO dto) {
|
||||
return geschichteService.update(id, dto);
|
||||
}
|
||||
|
||||
@@ -66,4 +75,45 @@ public class GeschichteController {
|
||||
geschichteService.delete(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ─── JourneyItem CRUD ────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{id}/items")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<JourneyItemView> appendItem(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyItemCreateDTO dto) {
|
||||
JourneyItemView view = journeyItemService.append(id, dto);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(view);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public JourneyItemView updateItemNote(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId,
|
||||
@RequestBody JourneyItemUpdateDTO dto) {
|
||||
return journeyItemService.updateNote(id, itemId, dto);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}/items/{itemId}")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
public ResponseEntity<Void> deleteItem(
|
||||
@PathVariable UUID id,
|
||||
@PathVariable UUID itemId) {
|
||||
journeyItemService.delete(id, itemId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/items/reorder")
|
||||
@RequirePermission(Permission.BLOG_WRITE)
|
||||
@Operation(
|
||||
summary = "Reorder journey items",
|
||||
description = "itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request."
|
||||
)
|
||||
public List<JourneyItemView> reorderItems(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody JourneyReorderDTO dto) {
|
||||
return journeyItemService.reorder(id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,47 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
|
||||
|
||||
/**
|
||||
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
|
||||
*
|
||||
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
|
||||
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
|
||||
*
|
||||
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
|
||||
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
|
||||
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
|
||||
g.author AS author, g.publishedAt AS publishedAt, g.updatedAt AS updatedAt, g.body AS body
|
||||
FROM Geschichte g
|
||||
WHERE g.status = :effectiveStatus
|
||||
AND (:authorId IS NULL OR g.author.id = :authorId)
|
||||
AND (:personCount = 0 OR
|
||||
(SELECT COUNT(DISTINCT p.id)
|
||||
FROM Geschichte g2 JOIN g2.persons p
|
||||
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
|
||||
AND (:documentId IS NULL OR
|
||||
EXISTS (SELECT 1 FROM JourneyItem ji
|
||||
WHERE ji.geschichte = g AND ji.document.id = :documentId))
|
||||
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
|
||||
""")
|
||||
List<GeschichteSummary> findSummaries(
|
||||
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
|
||||
@Param("authorId") UUID authorId,
|
||||
@Param("personIds") Collection<UUID> personIds,
|
||||
@Param("personCount") long personCount,
|
||||
@Param("documentId") UUID documentId);
|
||||
}
|
||||
|
||||
@@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.owasp.html.HtmlPolicyBuilder;
|
||||
import org.owasp.html.PolicyFactory;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -41,6 +36,7 @@ public class GeschichteService {
|
||||
private final PersonService personService;
|
||||
private final DocumentService documentService;
|
||||
private final UserService userService;
|
||||
private final JourneyItemService journeyItemService;
|
||||
|
||||
/**
|
||||
* Allow-list policy for Geschichte body HTML. Tiptap on the writer side
|
||||
@@ -54,12 +50,26 @@ public class GeschichteService {
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
private static final int MAX_LIMIT = 200;
|
||||
|
||||
/** Sentinel used when {@code personIds} is empty to avoid invalid empty IN() SQL. */
|
||||
private static final UUID NIL_UUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
|
||||
|
||||
// Matches the geschichten.title VARCHAR(255) column (V58) — the service check
|
||||
// turns what would be a DB-level 500 into a friendly 400.
|
||||
static final int MAX_TITLE_LENGTH = 255;
|
||||
// JOURNEY intros travel the verbatim (unsanitized) write path, so they get the
|
||||
// same three-layer bound as journey notes: frontend maxlength, this check, and
|
||||
// the V75 CHECK constraint. STORY bodies are sanitized Tiptap HTML and stay
|
||||
// unbounded on purpose.
|
||||
static final int MAX_INTRO_LENGTH = 4000;
|
||||
|
||||
// ─── Read API ────────────────────────────────────────────────────────────
|
||||
|
||||
public long countPublished() {
|
||||
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
|
||||
}
|
||||
|
||||
// readOnly = true: lazy collections resolve within the same tx when called from getView()
|
||||
@Transactional(readOnly = true)
|
||||
public Geschichte getById(UUID id) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
@@ -72,24 +82,62 @@ 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.
|
||||
*
|
||||
* <p>Security: {@code null} status always resolves to PUBLISHED — even for blog writers.
|
||||
* Only an explicit {@code DRAFT} request scopes the query to the caller's own drafts.
|
||||
* This prevents CWE-639: a blog writer passing {@code null} must not see all authors' drafts.
|
||||
*/
|
||||
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
|
||||
public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) {
|
||||
boolean isDraftRequest = currentUserHasBlogWrite() && status == GeschichteStatus.DRAFT;
|
||||
GeschichteStatus effective = isDraftRequest ? GeschichteStatus.DRAFT : 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())
|
||||
UUID authorId = isDraftRequest ? currentUser().getId() : null;
|
||||
|
||||
// When personIds is empty, personCount=0 short-circuits the IN() predicate.
|
||||
// Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
|
||||
Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
|
||||
? List.of(NIL_UUID)
|
||||
: personIds;
|
||||
long personCount = (personIds == null) ? 0 : personIds.size();
|
||||
|
||||
return geschichteRepository
|
||||
.findSummaries(effective, authorId, safePersonIds, personCount, documentId)
|
||||
.stream()
|
||||
.limit(safeLimit)
|
||||
.toList();
|
||||
@@ -97,46 +145,57 @@ public class GeschichteService {
|
||||
|
||||
// ─── Write API ───────────────────────────────────────────────────────────
|
||||
|
||||
// Write methods return GeschichteView, never the entity: Jackson serializes after
|
||||
// the transaction closed, where the lazy items collection is a dead proxy.
|
||||
// The view is assembled in-transaction, so no force-init tricks are needed.
|
||||
|
||||
@Transactional
|
||||
public Geschichte create(GeschichteUpdateDTO dto) {
|
||||
public GeschichteView create(GeschichteUpdateDTO dto) {
|
||||
requireTitle(dto.getTitle());
|
||||
GeschichteType type = dto.getType() != null ? dto.getType() : GeschichteType.STORY;
|
||||
Geschichte g = Geschichte.builder()
|
||||
.title(dto.getTitle().trim())
|
||||
.body(sanitize(dto.getBody()))
|
||||
.body(bodyForType(type, dto.getBody()))
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(type)
|
||||
.author(currentUser())
|
||||
.persons(resolvePersons(dto.getPersonIds()))
|
||||
.documents(resolveDocuments(dto.getDocumentIds()))
|
||||
.build();
|
||||
if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
|
||||
g.setStatus(GeschichteStatus.PUBLISHED);
|
||||
g.setPublishedAt(LocalDateTime.now());
|
||||
}
|
||||
return geschichteRepository.save(g);
|
||||
Geschichte saved = geschichteRepository.save(g);
|
||||
// A freshly created Geschichte has no items by construction — items are only
|
||||
// addable via the separate /items endpoints. Revisit if a create DTO ever
|
||||
// accepts initial items.
|
||||
return toView(saved, List.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Geschichte update(UUID id, GeschichteUpdateDTO dto) {
|
||||
public GeschichteView update(UUID id, GeschichteUpdateDTO dto) {
|
||||
Geschichte g = geschichteRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id));
|
||||
if (dto.getType() != null && dto.getType() != g.getType()) {
|
||||
throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE,
|
||||
"The type of a Geschichte cannot be changed after creation");
|
||||
}
|
||||
if (dto.getTitle() != null) {
|
||||
requireTitle(dto.getTitle());
|
||||
g.setTitle(dto.getTitle().trim());
|
||||
}
|
||||
if (dto.getBody() != null) {
|
||||
g.setBody(sanitize(dto.getBody()));
|
||||
g.setBody(bodyForType(g.getType(), dto.getBody()));
|
||||
}
|
||||
if (dto.getPersonIds() != null) {
|
||||
g.setPersons(resolvePersons(dto.getPersonIds()));
|
||||
}
|
||||
if (dto.getDocumentIds() != null) {
|
||||
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
|
||||
}
|
||||
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
|
||||
applyStatusTransition(g, dto.getStatus());
|
||||
}
|
||||
return geschichteRepository.save(g);
|
||||
Geschichte saved = geschichteRepository.save(g);
|
||||
return toView(saved, journeyItemService.getItems(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -164,6 +223,27 @@ public class GeschichteService {
|
||||
throw DomainException.badRequest(
|
||||
ErrorCode.VALIDATION_ERROR, "Title is required");
|
||||
}
|
||||
if (title.trim().length() > MAX_TITLE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.GESCHICHTE_TITLE_TOO_LONG,
|
||||
"Title exceeds maximum length of " + MAX_TITLE_LENGTH + " characters");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STORY bodies are Tiptap HTML and go through the OWASP allow-list sanitizer.
|
||||
* JOURNEY intros are plain text: the reader renders them via Svelte text
|
||||
* interpolation (never {@code {@html}}), so entity-encoding them here would
|
||||
* corrupt content ("&" → "&") and re-encode on every editor round-trip.
|
||||
*/
|
||||
private String bodyForType(GeschichteType type, String body) {
|
||||
if (type != GeschichteType.JOURNEY) {
|
||||
return sanitize(body);
|
||||
}
|
||||
if (body != null && body.length() > MAX_INTRO_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.GESCHICHTE_INTRO_TOO_LONG,
|
||||
"Intro exceeds maximum length of " + MAX_INTRO_LENGTH + " characters");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private String sanitize(String body) {
|
||||
@@ -176,15 +256,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,45 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* List-projection for the /api/geschichten grid. Never carries items — avoids
|
||||
* LazyInitializationException (open-in-view: false) and prevents Cartesian joins.
|
||||
* Mirrors the PersonSummaryDTO precedent.
|
||||
*
|
||||
* <p>Field set: exactly what the live grid card renders (title, author byline, body excerpt,
|
||||
* publishedAt, status, type). Does NOT carry items or persons.
|
||||
*/
|
||||
public interface GeschichteSummary {
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
UUID getId();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
String getTitle();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
GeschichteStatus getStatus();
|
||||
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
GeschichteType getType();
|
||||
|
||||
/** Nested closed projection — exposes only the fields the grid card needs. */
|
||||
AuthorSummary getAuthor();
|
||||
|
||||
LocalDateTime getPublishedAt();
|
||||
|
||||
/** Always set (@UpdateTimestamp) — drives "bearbeitet vor X" on dashboard cards. */
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
LocalDateTime getUpdatedAt();
|
||||
|
||||
String getBody();
|
||||
|
||||
/** Author projection — names only; never email or group memberships (same rule as GeschichteView.AuthorView). */
|
||||
interface AuthorSummary {
|
||||
String getFirstName();
|
||||
String getLastName();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -16,6 +15,6 @@ public class GeschichteUpdateDTO {
|
||||
private String title;
|
||||
private String body;
|
||||
private GeschichteStatus status;
|
||||
private GeschichteType type;
|
||||
private List<UUID> personIds;
|
||||
private List<UUID> documentIds;
|
||||
}
|
||||
|
||||
@@ -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,54 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "journey_items")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class JourneyItem {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "geschichte_id", nullable = false)
|
||||
@JsonIgnore
|
||||
private Geschichte geschichte;
|
||||
|
||||
// Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order
|
||||
// — the editor is responsible for keeping them distinct.
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private int position;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "document_id")
|
||||
@JsonIgnore
|
||||
private Document document;
|
||||
|
||||
/**
|
||||
* Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output.
|
||||
*
|
||||
* <p>CWE-79 tripwire: stored verbatim; only Svelte {note} interpolation is auto-safe.</p>
|
||||
*/
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String note;
|
||||
|
||||
// JPA uses field access — this getter is not persisted. Jackson serializes it as documentId.
|
||||
// Exposing only the UUID prevents circular references and large nested payloads.
|
||||
public UUID getDocumentId() {
|
||||
return document != null ? document.getId() : null;
|
||||
}
|
||||
}
|
||||
@@ -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,30 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.document.DocumentDeletingEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
class JourneyItemDocumentDeleteListener {
|
||||
|
||||
private final JourneyItemRepository journeyItemRepository;
|
||||
|
||||
/**
|
||||
* Plain @EventListener — runs synchronously in the publisher's thread and transaction.
|
||||
* Load-bearing choice: AFTER_COMMIT would fire after the FK ON DELETE SET NULL has
|
||||
* already 500'd; @Async would run outside the delete transaction (breaks AC-5 rollback).
|
||||
* See ADR-038. DocumentService cannot call JourneyItemService directly because
|
||||
* Spring Framework 7 prohibits the resulting constructor-injection cycle.
|
||||
*/
|
||||
@EventListener
|
||||
void onDocumentDeleting(DocumentDeletingEvent event) {
|
||||
int deleted = journeyItemRepository.deleteNoteLessByDocumentId(event.documentId());
|
||||
if (deleted > 0) {
|
||||
log.warn("Cascade-deleted {} note-less journey item(s) for document {}", deleted, event.documentId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID> {
|
||||
|
||||
/** Returns items ordered by position ASC for the read-model assembly path. */
|
||||
List<JourneyItem> findByGeschichteIdOrderByPosition(UUID geschichteId);
|
||||
|
||||
/** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */
|
||||
Optional<JourneyItem> findByIdAndGeschichteId(UUID id, UUID geschichteId);
|
||||
|
||||
/** Returns only the IDs — used for set-equality check in reorder. */
|
||||
@Query("SELECT i.id FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Set<UUID> findIdsByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** MAX position for computing the next append position; returns empty when journey has no items. */
|
||||
@Query("SELECT MAX(i.position) FROM JourneyItem i WHERE i.geschichte.id = :geschichteId")
|
||||
Optional<Integer> findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId);
|
||||
|
||||
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
||||
long countByGeschichteId(UUID geschichteId);
|
||||
|
||||
/**
|
||||
* Dedup guard: true when the document is already linked to this journey.
|
||||
* Explicit JPQL, not a derived query: the transient {@code getDocumentId()}
|
||||
* getter on JourneyItem makes Spring Data resolve the derived path as a
|
||||
* direct {@code documentId} attribute, which Hibernate cannot map.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT COUNT(i) > 0 FROM JourneyItem i
|
||||
WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId
|
||||
""")
|
||||
boolean existsByGeschichteIdAndDocumentId(
|
||||
@Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId);
|
||||
|
||||
/**
|
||||
* Deletes note-less items (note IS NULL or note = '') linked to the given document.
|
||||
* Used by JourneyItemDocumentDeleteListener before the document row is removed, so
|
||||
* the FK ON DELETE SET NULL never fires on rows that would violate chk_journey_item_not_empty.
|
||||
* Explicit JPQL — same trap as existsByGeschichteIdAndDocumentId: the transient
|
||||
* getDocumentId() getter makes Spring Data unable to resolve a derived query path.
|
||||
* clearAutomatically = true invalidates the L1 cache so AC-2's "note-carrying survives"
|
||||
* assertion never reads a stale entity. flushAutomatically = true makes the
|
||||
* flush-before-delete contract explicit rather than relying on Hibernate AUTO flush mode.
|
||||
*/
|
||||
@Modifying(clearAutomatically = true, flushAutomatically = true)
|
||||
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
|
||||
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
|
||||
|
||||
/**
|
||||
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
||||
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
||||
* lazily for each item. Items without a document (note-only) are included via
|
||||
* LEFT JOIN. Ordered by position ASC.
|
||||
*/
|
||||
@Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC")
|
||||
List<JourneyItem> findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.PersonNameFormatter;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class JourneyItemService {
|
||||
|
||||
static final int MAX_ITEMS = 100;
|
||||
static final int POSITION_STEP = 10;
|
||||
// 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793).
|
||||
static final int MAX_NOTE_LENGTH = 2000;
|
||||
|
||||
private final JourneyItemRepository journeyItemRepository;
|
||||
private final GeschichteQueryService geschichteQueryService;
|
||||
private final DocumentService documentService;
|
||||
private final AuditService auditService;
|
||||
private final UserService userService;
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) {
|
||||
Geschichte g = geschichteQueryService.findById(geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Geschichte not found: " + geschichteId));
|
||||
|
||||
long count = journeyItemRepository.countByGeschichteId(geschichteId);
|
||||
if (count >= MAX_ITEMS) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY,
|
||||
"Journey has reached the maximum of 100 items");
|
||||
}
|
||||
|
||||
String note = normalizeNote(dto.getNote());
|
||||
|
||||
if (dto.getDocumentId() == null && note == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"At least one of documentId or note must be provided");
|
||||
}
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
Document doc = null;
|
||||
if (dto.getDocumentId() != null) {
|
||||
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
"Document already in journey: " + dto.getDocumentId());
|
||||
}
|
||||
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
||||
}
|
||||
|
||||
int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)
|
||||
.map(max -> max + POSITION_STEP)
|
||||
.orElse(POSITION_STEP);
|
||||
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(g)
|
||||
.position(nextPosition)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
// saveAndFlush so the partial unique index on (geschichte_id, document_id)
|
||||
// fires here, not at commit — two concurrent appends can both pass the
|
||||
// exists() pre-check above, and the index is the atomic backstop (V74).
|
||||
JourneyItem saved;
|
||||
try {
|
||||
saved = journeyItemRepository.saveAndFlush(item);
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
// Only the dedup index earns the friendly 409 — any other integrity
|
||||
// failure (e.g. an FK violation on a concurrently deleted document)
|
||||
// must not be mislabeled as "already added".
|
||||
if (!isDuplicateDocumentViolation(e)) {
|
||||
throw e;
|
||||
}
|
||||
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||
"Document already in journey: " + dto.getDocumentId());
|
||||
}
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", saved.getId()));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
// null = field absent from JSON → no-op
|
||||
Optional<String> noteField = dto.getNote();
|
||||
if (noteField == null) {
|
||||
return toView(item);
|
||||
}
|
||||
|
||||
String note = normalizeNote(noteField.orElse(null));
|
||||
|
||||
if (note != null && note.length() > MAX_NOTE_LENGTH) {
|
||||
throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG,
|
||||
"Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters");
|
||||
}
|
||||
|
||||
if (note == null && item.getDocumentId() == null) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Cannot clear note on an item that has no linked document");
|
||||
}
|
||||
|
||||
item.setNote(note);
|
||||
JourneyItem saved = journeyItemRepository.save(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
|
||||
return toView(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(UUID geschichteId, UUID itemId) {
|
||||
JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND,
|
||||
"Journey item not found: " + itemId));
|
||||
|
||||
journeyItemRepository.delete(item);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemId", itemId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public List<JourneyItemView> reorder(UUID geschichteId, JourneyReorderDTO dto) {
|
||||
if (!geschichteQueryService.existsById(geschichteId)) {
|
||||
throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND,
|
||||
"Geschichte not found: " + geschichteId);
|
||||
}
|
||||
Set<UUID> existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId);
|
||||
List<UUID> requestedIds = dto.getItemIds() != null ? dto.getItemIds() : List.of();
|
||||
|
||||
if (requestedIds.size() != new HashSet<>(requestedIds).size()) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Duplicate item IDs in reorder request");
|
||||
}
|
||||
|
||||
if (!existingIds.equals(new HashSet<>(requestedIds))) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"Requested item IDs do not match the journey's existing items");
|
||||
}
|
||||
|
||||
if (requestedIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
List<JourneyItem> items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId);
|
||||
Map<UUID, JourneyItem> itemMap = new HashMap<>();
|
||||
for (JourneyItem item : items) {
|
||||
itemMap.put(item.getId(), item);
|
||||
}
|
||||
|
||||
List<JourneyItem> toSave = new ArrayList<>(requestedIds.size());
|
||||
for (int i = 0; i < requestedIds.size(); i++) {
|
||||
JourneyItem item = itemMap.get(requestedIds.get(i));
|
||||
item.setPosition((i + 1) * POSITION_STEP);
|
||||
toSave.add(item);
|
||||
}
|
||||
List<JourneyItem> reordered = journeyItemRepository.saveAll(toSave);
|
||||
|
||||
UUID actorId = currentUser().getId();
|
||||
auditService.logAfterCommit(AuditKind.JOURNEY_ITEMS_REORDERED, actorId, null,
|
||||
Map.of("geschichteId", geschichteId, "itemCount", reordered.size()));
|
||||
|
||||
return reordered.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
public List<JourneyItemView> getItems(UUID geschichteId) {
|
||||
return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId)
|
||||
.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
DocumentSummary toSummary(Document doc) {
|
||||
String senderName = buildSenderName(doc);
|
||||
Set<Person> receivers = doc.getReceivers();
|
||||
String receiverName = buildCanonicalReceiverName(receivers);
|
||||
|
||||
return new DocumentSummary(
|
||||
doc.getId(),
|
||||
doc.getTitle(),
|
||||
doc.getDocumentDate(),
|
||||
doc.getMetaDateEnd(),
|
||||
doc.getMetaDatePrecision() != null ? doc.getMetaDatePrecision() : DatePrecision.UNKNOWN,
|
||||
senderName,
|
||||
receiverName,
|
||||
receivers != null ? receivers.size() : 0
|
||||
);
|
||||
}
|
||||
|
||||
JourneyItemView toView(JourneyItem item) {
|
||||
DocumentSummary docSummary = null;
|
||||
Document doc = item.getDocument();
|
||||
if (doc != null) {
|
||||
docSummary = toSummary(doc);
|
||||
}
|
||||
return new JourneyItemView(item.getId(), item.getPosition(), docSummary, item.getNote());
|
||||
}
|
||||
|
||||
private static String buildSenderName(Document doc) {
|
||||
Person sender = doc.getSender();
|
||||
if (sender != null) {
|
||||
String name = PersonNameFormatter.join(sender.getFirstName(), sender.getLastName());
|
||||
if (!name.isBlank()) return name;
|
||||
}
|
||||
String senderText = doc.getSenderText();
|
||||
return (senderText != null && !senderText.isBlank()) ? senderText : null;
|
||||
}
|
||||
|
||||
private static String buildCanonicalReceiverName(Set<Person> receivers) {
|
||||
if (receivers == null || receivers.isEmpty()) return null;
|
||||
return receivers.stream()
|
||||
.min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName())))
|
||||
.map(p -> {
|
||||
String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName());
|
||||
return name.isBlank() ? null : name;
|
||||
})
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof java.sql.SQLException sql) {
|
||||
return "23505".equals(sql.getSQLState());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalizeNote(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
private static String sortKey(String s) {
|
||||
return s != null ? s : "";
|
||||
}
|
||||
|
||||
private AppUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
return userService.findByEmail(auth.getName());
|
||||
}
|
||||
}
|
||||
@@ -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,17 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Read-model response for a JourneyItem. Never the JPA entity (which has a
|
||||
* Geschichte back-reference that would leak / hit LazyInitializationException).
|
||||
*/
|
||||
public record JourneyItemView(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position,
|
||||
DocumentSummary document,
|
||||
/** Plain text — not HTML-sanitized. Renderers MUST NOT use {@code @html} or equivalent unsafe output. */
|
||||
String note
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.user.DisplayNameFormatter;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -49,8 +51,25 @@ public class Person {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String notes;
|
||||
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
// Most precise birth/death date known. Precision mirrors Document.metaDatePrecision:
|
||||
// the date column is nullable, the precision column is NOT NULL with UNKNOWN meaning
|
||||
// "no date" — the V76 CHECK constraints enforce (date IS NULL) = (precision = UNKNOWN).
|
||||
// DatePrecision is imported cross-domain from document/ by design (ADR-039).
|
||||
private LocalDate birthDate;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "birth_date_precision", nullable = false, length = 16)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private DatePrecision birthDatePrecision = DatePrecision.UNKNOWN;
|
||||
|
||||
private LocalDate deathDate;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "death_date_precision", nullable = false, length = 16)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Builder.Default
|
||||
private DatePrecision deathDatePrecision = DatePrecision.UNKNOWN;
|
||||
|
||||
// Hand-curated generation index from canonical-persons.xlsx (G 0 = oldest).
|
||||
// Nullable for persons outside the curated family graph. Drives the
|
||||
|
||||
@@ -66,7 +66,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
@@ -79,7 +82,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
@@ -89,7 +95,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
OR LOWER(CONCAT(p.last_name,' ',COALESCE(p.first_name,''))) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
OR LOWER(a.last_name) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_year, p.death_year, p.notes, p.family_member, p.provisional
|
||||
GROUP BY p.id, p.title, p.first_name, p.last_name, p.person_type, p.alias, p.birth_date, p.birth_date_precision, p.death_date, p.death_date_precision, p.notes, p.family_member, p.provisional
|
||||
ORDER BY p.last_name ASC, p.first_name ASC
|
||||
""",
|
||||
nativeQuery = true)
|
||||
@@ -100,7 +106,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
@@ -139,7 +148,10 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
@Query(value = """
|
||||
SELECT p.id, p.title, p.first_name AS firstName, p.last_name AS lastName,
|
||||
p.person_type AS personType,
|
||||
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||
p.alias, CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear,
|
||||
CAST(EXTRACT(YEAR FROM p.death_date) AS int) AS deathYear,
|
||||
p.birth_date AS birthDate, p.birth_date_precision AS birthDatePrecision,
|
||||
p.death_date AS deathDate, p.death_date_precision AS deathDatePrecision, p.notes,
|
||||
p.family_member AS familyMember, p.provisional AS provisional,
|
||||
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -16,6 +17,7 @@ import org.springframework.lang.Nullable;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
@@ -299,13 +301,20 @@ public class PersonService {
|
||||
}
|
||||
|
||||
private Person fromCanonical(PersonUpsertCommand cmd) {
|
||||
DatePrecisionPair none = new DatePrecisionPair(null, DatePrecision.UNKNOWN);
|
||||
LifeDates dates = degradeIfConflicting(
|
||||
yearPair(cmd.birthYear()), yearPair(cmd.deathYear()), none, none, cmd.sourceRef());
|
||||
DatePrecisionPair birth = dates.birth();
|
||||
DatePrecisionPair death = dates.death();
|
||||
Person person = personRepository.save(Person.builder()
|
||||
.sourceRef(cmd.sourceRef())
|
||||
.firstName(blankToNull(cmd.firstName()))
|
||||
.lastName(cmd.lastName())
|
||||
.notes(blankToNull(cmd.notes()))
|
||||
.birthYear(cmd.birthYear())
|
||||
.deathYear(cmd.deathYear())
|
||||
.birthDate(birth.date())
|
||||
.birthDatePrecision(birth.precision())
|
||||
.deathDate(death.date())
|
||||
.deathDatePrecision(death.precision())
|
||||
.generation(cmd.generation())
|
||||
.familyMember(cmd.familyMember())
|
||||
.personType(cmd.personType() == null ? PersonType.PERSON : cmd.personType())
|
||||
@@ -328,8 +337,16 @@ public class PersonService {
|
||||
existing.setFirstName(preferHuman(existing.getFirstName(), cmd.firstName()));
|
||||
existing.setLastName(preferHuman(existing.getLastName(), cmd.lastName()));
|
||||
existing.setNotes(preferHuman(existing.getNotes(), cmd.notes()));
|
||||
existing.setBirthYear(preferHuman(existing.getBirthYear(), cmd.birthYear()));
|
||||
existing.setDeathYear(preferHuman(existing.getDeathYear(), cmd.deathYear()));
|
||||
LifeDates dates = degradeIfConflicting(
|
||||
preferHumanDate(existing.getBirthDate(), existing.getBirthDatePrecision(), cmd.birthYear()),
|
||||
preferHumanDate(existing.getDeathDate(), existing.getDeathDatePrecision(), cmd.deathYear()),
|
||||
new DatePrecisionPair(existing.getBirthDate(), existing.getBirthDatePrecision()),
|
||||
new DatePrecisionPair(existing.getDeathDate(), existing.getDeathDatePrecision()),
|
||||
cmd.sourceRef());
|
||||
existing.setBirthDate(dates.birth().date());
|
||||
existing.setBirthDatePrecision(dates.birth().precision());
|
||||
existing.setDeathDate(dates.death().date());
|
||||
existing.setDeathDatePrecision(dates.death().precision());
|
||||
existing.setGeneration(preferHuman(existing.getGeneration(), cmd.generation()));
|
||||
if (cmd.personType() != null && existing.getPersonType() == PersonType.PERSON) {
|
||||
existing.setPersonType(cmd.personType());
|
||||
@@ -356,6 +373,48 @@ public class PersonService {
|
||||
return existing != null ? existing : canonical;
|
||||
}
|
||||
|
||||
// Date + precision travel as one value so they can never go out of sync (ADR-039).
|
||||
record DatePrecisionPair(LocalDate date, DatePrecision precision) {}
|
||||
|
||||
record LifeDates(DatePrecisionPair birth, DatePrecisionPair death) {}
|
||||
|
||||
// The canonical path skips validateLifeDates (the form-only guard), so a conflicting
|
||||
// resolved pair would hit chk_person_birth_before_death at flush time and abort the
|
||||
// whole import batch with a raw 500. Degrade instead (REQ-IMP-001: never abort the
|
||||
// batch): keep the person's stored life dates — empty for a new person — and drop the
|
||||
// conflicting canonical refresh. A hand-entered side is preserved by construction,
|
||||
// since preferHumanDate returned it verbatim and it equals the stored value; two
|
||||
// stored values can never conflict with each other (they already satisfied the CHECK).
|
||||
static LifeDates degradeIfConflicting(DatePrecisionPair birth, DatePrecisionPair death,
|
||||
DatePrecisionPair existingBirth, DatePrecisionPair existingDeath,
|
||||
String sourceRef) {
|
||||
if (birth.date() == null || death.date() == null || !birth.date().isAfter(death.date())) {
|
||||
return new LifeDates(birth, death);
|
||||
}
|
||||
log.warn("Conflicting canonical life dates for {}: birth {} is after death {} — keeping stored values",
|
||||
sourceRef, birth.date(), death.date());
|
||||
return new LifeDates(existingBirth, existingDeath);
|
||||
}
|
||||
|
||||
// preferHuman for life dates (ADR-025 extension): a hand-entered date more precise than
|
||||
// the spreadsheet's year (DAY/MONTH/SEASON/RANGE/APPROX) is preserved on re-import; a
|
||||
// YEAR-precision or absent date is refreshed from the canonical year.
|
||||
static DatePrecisionPair preferHumanDate(LocalDate existingDate, DatePrecision existingPrecision,
|
||||
Integer canonicalYear) {
|
||||
boolean handEntered = existingDate != null && existingPrecision != null
|
||||
&& existingPrecision != DatePrecision.YEAR && existingPrecision != DatePrecision.UNKNOWN;
|
||||
if (handEntered) {
|
||||
return new DatePrecisionPair(existingDate, existingPrecision);
|
||||
}
|
||||
return yearPair(canonicalYear);
|
||||
}
|
||||
|
||||
private static DatePrecisionPair yearPair(Integer year) {
|
||||
return year != null
|
||||
? new DatePrecisionPair(LocalDate.of(year, 1, 1), DatePrecision.YEAR)
|
||||
: new DatePrecisionPair(null, DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
@@ -375,7 +434,8 @@ public class PersonService {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual creation");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||
Person person = Person.builder()
|
||||
.personType(dto.getPersonType())
|
||||
.title(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim())
|
||||
@@ -383,31 +443,49 @@ public class PersonService {
|
||||
.lastName(dto.getLastName())
|
||||
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||
.birthYear(dto.getBirthYear())
|
||||
.deathYear(dto.getDeathYear())
|
||||
.birthDate(dto.getBirthDate())
|
||||
.birthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()))
|
||||
.deathDate(dto.getDeathDate())
|
||||
.deathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()))
|
||||
.generation(dto.getGeneration())
|
||||
.build();
|
||||
return personRepository.save(person);
|
||||
}
|
||||
|
||||
private void validateYears(Integer birthYear, Integer deathYear) {
|
||||
if (birthYear != null && birthYear <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
|
||||
// Cross-field invariants the V76 CHECK constraints also enforce — validated here so the
|
||||
// user gets a structured ErrorCode instead of a raw constraint-violation 500.
|
||||
private void validateLifeDates(LocalDate birthDate, DatePrecision birthPrecision,
|
||||
LocalDate deathDate, DatePrecision deathPrecision) {
|
||||
requireDatePrecisionCoherence(birthDate, birthPrecision, "birth");
|
||||
requireDatePrecisionCoherence(deathDate, deathPrecision, "death");
|
||||
if (birthDate != null && deathDate != null && birthDate.isAfter(deathDate)) {
|
||||
throw DomainException.badRequest(ErrorCode.BIRTH_AFTER_DEATH,
|
||||
"Birth date " + birthDate + " is after death date " + deathDate);
|
||||
}
|
||||
if (deathYear != null && deathYear <= 0) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
|
||||
}
|
||||
|
||||
private static void requireDatePrecisionCoherence(LocalDate date, DatePrecision precision, String side) {
|
||||
if (date != null && (precision == null || precision == DatePrecision.UNKNOWN)) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||
side + " date is set but its precision is missing or UNKNOWN");
|
||||
}
|
||||
if (birthYear != null && deathYear != null && birthYear > deathYear) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
||||
if (date == null && precision != null && precision != DatePrecision.UNKNOWN) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_DATE_PRECISION,
|
||||
side + " date precision " + precision + " is set without a date");
|
||||
}
|
||||
}
|
||||
|
||||
private static DatePrecision normalizePrecision(DatePrecision precision) {
|
||||
return precision == null ? DatePrecision.UNKNOWN : precision;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||
if (dto.getPersonType() == PersonType.SKIP) {
|
||||
throw DomainException.badRequest(ErrorCode.INVALID_PERSON_TYPE, "SKIP is not a valid person type for manual editing");
|
||||
}
|
||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||
validateLifeDates(dto.getBirthDate(), dto.getBirthDatePrecision(),
|
||||
dto.getDeathDate(), dto.getDeathDatePrecision());
|
||||
Person person = personRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||
person.setPersonType(dto.getPersonType());
|
||||
@@ -416,8 +494,10 @@ public class PersonService {
|
||||
person.setLastName(dto.getLastName());
|
||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||
person.setBirthYear(dto.getBirthYear());
|
||||
person.setDeathYear(dto.getDeathYear());
|
||||
person.setBirthDate(dto.getBirthDate());
|
||||
person.setBirthDatePrecision(normalizePrecision(dto.getBirthDatePrecision()));
|
||||
person.setDeathDate(dto.getDeathDate());
|
||||
person.setDeathDatePrecision(normalizePrecision(dto.getDeathDatePrecision()));
|
||||
// Form path: a human can clear generation back to null. Unlike the importer
|
||||
// which routes through preferHuman, we write the DTO value verbatim.
|
||||
person.setGeneration(dto.getGeneration());
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
@@ -16,6 +19,13 @@ public interface PersonSummaryDTO {
|
||||
String getAlias();
|
||||
Integer getBirthYear();
|
||||
Integer getDeathYear();
|
||||
// Full date + precision alongside the derived years: list consumers that render
|
||||
// precise life dates (mention dropdown) read these; year-only consumers keep
|
||||
// the cheaper getBirthYear/getDeathYear.
|
||||
LocalDate getBirthDate();
|
||||
DatePrecision getBirthDatePrecision();
|
||||
LocalDate getDeathDate();
|
||||
DatePrecision getDeathDatePrecision();
|
||||
String getNotes();
|
||||
boolean isFamilyMember();
|
||||
boolean isProvisional();
|
||||
|
||||
@@ -5,8 +5,11 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.person.PersonType;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
@Data
|
||||
public class PersonUpdateDTO {
|
||||
@NotNull
|
||||
@@ -21,8 +24,10 @@ public class PersonUpdateDTO {
|
||||
private String alias;
|
||||
@Size(max = 5000)
|
||||
private String notes;
|
||||
private Integer birthYear;
|
||||
private Integer deathYear;
|
||||
private LocalDate birthDate;
|
||||
private DatePrecision birthDatePrecision;
|
||||
private LocalDate deathDate;
|
||||
private DatePrecision deathDatePrecision;
|
||||
// Mirror of the persons.generation CHECK constraint (V70). Bounds live in
|
||||
// PersonGeneration so DB, DTO, and importer all read from one place.
|
||||
@Min(PersonGeneration.MIN_GENERATION)
|
||||
|
||||
@@ -96,7 +96,9 @@ public class RelationshipInferenceService {
|
||||
if (p == null) continue;
|
||||
List<RelationToken> path = shortestPaths.get(id);
|
||||
PersonNodeDTO node = new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||
p.getId(), p.getDisplayName(),
|
||||
RelationshipService.yearOf(p.getBirthDate()),
|
||||
RelationshipService.yearOf(p.getDeathDate()),
|
||||
p.getGeneration(), p.isFamilyMember());
|
||||
out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size()));
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -66,7 +67,8 @@ public class RelationshipService {
|
||||
for (Person p : familyMembers) {
|
||||
familyIds.add(p.getId());
|
||||
nodes.add(new PersonNodeDTO(
|
||||
p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(),
|
||||
p.getId(), p.getDisplayName(),
|
||||
yearOf(p.getBirthDate()), yearOf(p.getDeathDate()),
|
||||
p.getGeneration(), true));
|
||||
}
|
||||
|
||||
@@ -155,6 +157,13 @@ public class RelationshipService {
|
||||
return (s == null || s.isBlank()) ? null : s.trim();
|
||||
}
|
||||
|
||||
// Stammbaum DTOs stay year-shaped: derive the year from the LocalDate, null-safe
|
||||
// for persons with no date entered (ADR-039, REQ-PERSON-DATE-01). Package-private
|
||||
// so RelationshipInferenceService shares the same derivation.
|
||||
static Integer yearOf(LocalDate date) {
|
||||
return date != null ? date.getYear() : null;
|
||||
}
|
||||
|
||||
private static void validateYears(Integer fromYear, Integer toYear) {
|
||||
if (fromYear != null && toYear != null && toYear < fromYear) {
|
||||
throw DomainException.badRequest(
|
||||
@@ -170,11 +179,11 @@ public class RelationshipService {
|
||||
p.getId(),
|
||||
rp.getId(),
|
||||
p.getDisplayName(),
|
||||
p.getBirthYear(),
|
||||
p.getDeathYear(),
|
||||
yearOf(p.getBirthDate()),
|
||||
yearOf(p.getDeathDate()),
|
||||
rp.getDisplayName(),
|
||||
rp.getBirthYear(),
|
||||
rp.getDeathYear(),
|
||||
yearOf(rp.getBirthDate()),
|
||||
yearOf(rp.getDeathDate()),
|
||||
r.getRelationType(),
|
||||
r.getFromYear(),
|
||||
r.getToYear(),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
/**
|
||||
* Kind of a curated {@link TimelineEvent}.
|
||||
*
|
||||
* <p>The string value names are a <strong>stable frontend styling contract</strong>: the
|
||||
* Svelte timeline components hard-code {@code "PERSONAL"} (family accent) and
|
||||
* {@code "HISTORICAL"} (muted world accent) as Tailwind class-map keys. There is no
|
||||
* mapping layer — renaming either value requires a coordinated frontend change. See
|
||||
* ADR-040.
|
||||
*/
|
||||
public enum EventType {
|
||||
/** A family/personal event (birth, wedding, move) — rendered with the family accent. */
|
||||
PERSONAL,
|
||||
/** A world/historical event providing context — rendered with the muted world accent. */
|
||||
HISTORICAL
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.BatchSize;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A curated event on the family timeline (Zeitstrahl). Unlike a {@link Document}, which is
|
||||
* OCR-derived, a {@code TimelineEvent} is authored by curators — hence the optimistic-lock
|
||||
* {@link #version} and the {@link #createdBy}/{@link #updatedBy} audit trail that
|
||||
* {@code Document} lacks.
|
||||
*
|
||||
* <p>The date block ({@link #eventDate}, {@link #precision}, {@link #eventDateEnd}) mirrors
|
||||
* {@code Document}'s so events and letters share one rendering path. The mirror applies to
|
||||
* the date block only — the audit footprint deliberately diverges (see ADR-040).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "timeline_events")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class TimelineEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID id;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String title;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 16)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private EventType type;
|
||||
|
||||
/** The most precise date known for the event. Always present — a curated event is never undated. */
|
||||
@Column(name = "event_date", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDate eventDate;
|
||||
|
||||
/**
|
||||
* Precision of {@link #eventDate}. Reuses {@code document.DatePrecision} (one rendering
|
||||
* path; see ADR-025 / ADR-040). Every value except {@code UNKNOWN} is legal for a curated
|
||||
* event — including {@code SEASON} ("Sommer 1914") and {@code APPROX} ("ca. 1914"). The DB
|
||||
* CHECK forbids exactly {@code UNKNOWN}; do not narrow it further.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "date_precision", nullable = false, length = 16)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private DatePrecision precision = DatePrecision.YEAR;
|
||||
|
||||
/** Range end — non-null <strong>iff</strong> {@link #precision} is {@code RANGE} (DB CHECK, both directions). */
|
||||
@Column(name = "event_date_end")
|
||||
private LocalDate eventDateEnd;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
/** People the event involves. */
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "timeline_event_persons",
|
||||
joinColumns = @JoinColumn(name = "timeline_event_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "person_id"))
|
||||
@BatchSize(size = 50)
|
||||
@Builder.Default
|
||||
private Set<Person> persons = new HashSet<>();
|
||||
|
||||
/** Optional supporting letters linked to the event. */
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@JoinTable(name = "timeline_event_documents",
|
||||
joinColumns = @JoinColumn(name = "timeline_event_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "document_id"))
|
||||
@BatchSize(size = 50)
|
||||
@Builder.Default
|
||||
private Set<Document> documents = new HashSet<>();
|
||||
|
||||
/**
|
||||
* UUID of the {@code AppUser} who created the event. Bare UUID, no FK to {@code app_users}
|
||||
* (sidecar pattern — keeps {@code timeline} decoupled from {@code user}). Server-populated
|
||||
* from the session principal; never accepted from client input (authorship-forgery vector,
|
||||
* CWE-639 — see ADR-040).
|
||||
*/
|
||||
@Column(name = "created_by", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID createdBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* UUID of the {@code AppUser} who last edited the event. Populated from the session
|
||||
* principal in {@code TimelineEventService}; <strong>must be set before every
|
||||
* {@code save()}</strong> — {@code @UpdateTimestamp} on {@link #updatedAt} does NOT set this
|
||||
* automatically, so without an explicit set the timestamp advances while the "who" goes
|
||||
* stale. Same forgery rationale as {@link #createdBy}.
|
||||
*/
|
||||
@Column(name = "updated_by", nullable = false)
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private UUID updatedBy;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Optimistic-lock version for the multi-curator edit flow (#775). Object {@code Long}
|
||||
* (not primitive) so it is {@code null} before first persist; Hibernate sets {@code 0} on
|
||||
* insert. A concurrent-write conflict must be translated to {@code DomainException.conflict}
|
||||
* in the service layer (ADR-040) — otherwise it surfaces as HTTP 500 with Hibernate
|
||||
* internals (CWE-209).
|
||||
*/
|
||||
@Version
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long version;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface TimelineEventRepository extends JpaRepository<TimelineEvent, UUID> {
|
||||
// TODO(#777): findByPersonsContaining(Person) needed for the per-person filter
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Two constraints the service-level checks need as atomic backstops:
|
||||
--
|
||||
-- 1. Partial unique index on (geschichte_id, document_id): the append dedup
|
||||
-- guard is a check-then-insert (existsByGeschichteIdAndDocumentId), so two
|
||||
-- concurrent appends of the same document can both pass the pre-check.
|
||||
-- The index rejects the second INSERT; JourneyItemService.append translates
|
||||
-- the DataIntegrityViolationException into the same 409
|
||||
-- JOURNEY_DOCUMENT_ALREADY_ADDED as the friendly pre-check.
|
||||
-- Partial (WHERE document_id IS NOT NULL) — note-only interludes must not collide.
|
||||
--
|
||||
-- 2. CHECK on note length: mirrors chk_text_length on transcription_blocks.
|
||||
-- 2000 is the spec'd limit — JourneyItemService.MAX_NOTE_LENGTH, the frontend
|
||||
-- maxlength, and the i18n error message all agree (#793).
|
||||
--
|
||||
-- Defensive cleanup first: a database that served writes on the base branch
|
||||
-- (no dedup guard, MAX_NOTE_LENGTH = 5000) can hold rows that would make the
|
||||
-- DDL below fail mid-migration and boot-loop the backend on a failed Flyway
|
||||
-- row. Both statements are no-ops on a clean database.
|
||||
|
||||
-- Keep the earliest-positioned row of each (geschichte, document) pair.
|
||||
DELETE FROM journey_items a
|
||||
USING journey_items b
|
||||
WHERE a.geschichte_id = b.geschichte_id
|
||||
AND a.document_id = b.document_id
|
||||
AND a.document_id IS NOT NULL
|
||||
AND a.position > b.position;
|
||||
|
||||
-- Clamp over-long notes written under the old 5000-char service limit.
|
||||
UPDATE journey_items SET note = left(note, 2000) WHERE length(note) > 2000;
|
||||
|
||||
CREATE UNIQUE INDEX uq_journey_items_geschichte_document
|
||||
ON journey_items (geschichte_id, document_id)
|
||||
WHERE document_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE journey_items
|
||||
ADD CONSTRAINT chk_journey_item_note_length
|
||||
CHECK (note IS NULL OR length(note) <= 2000);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- JOURNEY intros travel the verbatim (unsanitized) write path and get the same
|
||||
-- three-layer bound as journey notes: frontend maxlength, the
|
||||
-- GeschichteService.MAX_INTRO_LENGTH check, and this CHECK as the atomic backstop.
|
||||
-- STORY bodies are sanitized Tiptap HTML and stay unbounded on purpose.
|
||||
--
|
||||
-- The title needs no CHECK here — VARCHAR(255) (V58) already bounds it at the
|
||||
-- DB layer; the service-level check exists to turn that 500 into a friendly 400.
|
||||
|
||||
-- Defensive clamp first: intros written before this migration may exceed the
|
||||
-- cap. No-op on a clean database.
|
||||
UPDATE geschichten SET body = left(body, 4000)
|
||||
WHERE type = 'JOURNEY' AND length(body) > 4000;
|
||||
|
||||
ALTER TABLE geschichten
|
||||
ADD CONSTRAINT chk_geschichte_journey_intro_length
|
||||
CHECK (type <> 'JOURNEY' OR body IS NULL OR length(body) <= 4000);
|
||||
@@ -0,0 +1,42 @@
|
||||
-- V76: persons.birth_year/death_year (integer) → birth_date/death_date (date)
|
||||
-- plus NOT NULL precision columns mirroring documents.meta_date_precision.
|
||||
-- Existing years are backfilled as YYYY-01-01 at YEAR precision (ADR-039).
|
||||
-- One-way migration: rollback is a targeted pg_restore -t persons from the
|
||||
-- pre-deploy backup (see docs/DEPLOYMENT.md).
|
||||
|
||||
-- Pre-check (data quality gate — not a race guard): abort on corrupt year data
|
||||
-- before any DDL runs. Single-writer family archive, so no race window matters.
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year)
|
||||
THEN RAISE EXCEPTION 'V76 aborted: % persons have birth_year > death_year — fix data before migrating',
|
||||
(SELECT COUNT(*) FROM persons WHERE birth_year IS NOT NULL AND death_year IS NOT NULL AND birth_year > death_year);
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM persons WHERE birth_year = 0 OR death_year = 0)
|
||||
THEN RAISE EXCEPTION 'V76 aborted: persons table contains birth_year=0 or death_year=0 rows — clean data before migrating';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE persons ADD COLUMN birth_date date;
|
||||
ALTER TABLE persons ADD COLUMN birth_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||
ALTER TABLE persons ADD COLUMN death_date date;
|
||||
ALTER TABLE persons ADD COLUMN death_date_precision varchar(16) NOT NULL DEFAULT 'UNKNOWN';
|
||||
|
||||
UPDATE persons SET birth_date = make_date(birth_year, 1, 1), birth_date_precision = 'YEAR'
|
||||
WHERE birth_year IS NOT NULL;
|
||||
UPDATE persons SET death_date = make_date(death_year, 1, 1), death_date_precision = 'YEAR'
|
||||
WHERE death_year IS NOT NULL;
|
||||
|
||||
-- Named constraints: readable Postgres error messages when violated.
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_before_death
|
||||
CHECK (death_date IS NULL OR birth_date IS NULL OR birth_date <= death_date);
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_coherence
|
||||
CHECK ((birth_date IS NULL) = (birth_date_precision = 'UNKNOWN'));
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_birth_date_precision_values
|
||||
CHECK (birth_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_coherence
|
||||
CHECK ((death_date IS NULL) = (death_date_precision = 'UNKNOWN'));
|
||||
ALTER TABLE persons ADD CONSTRAINT chk_person_death_date_precision_values
|
||||
CHECK (death_date_precision IN ('DAY', 'MONTH', 'SEASON', 'YEAR', 'RANGE', 'APPROX', 'UNKNOWN'));
|
||||
|
||||
ALTER TABLE persons DROP COLUMN birth_year;
|
||||
ALTER TABLE persons DROP COLUMN death_year;
|
||||
@@ -0,0 +1,64 @@
|
||||
-- V77: timeline domain foundation (Zeitstrahl) — curated timeline events.
|
||||
-- Forward-only, additive DDL. No rollback script: rollback requires manual DROP TABLE
|
||||
-- (timeline_event_documents, timeline_event_persons, then timeline_events). See ADR-040.
|
||||
--
|
||||
-- The date block (event_date / date_precision / event_date_end) mirrors documents' so events
|
||||
-- and letters share one rendering path. Two divergences from documents are INTENTIONAL and
|
||||
-- enforced here in Postgres (ADR-040):
|
||||
-- 1. The RANGE rule is a strict biconditional (event_date_end non-null IFF RANGE), unlike
|
||||
-- documents' open-ended ranges — a curated event always has a known, closed end.
|
||||
-- 2. date_precision <> 'UNKNOWN' — only OCR-inferred letters are ever undated; a curated
|
||||
-- event always has at least a year. SEASON and APPROX stay legal (Sommer/ca. 1914).
|
||||
|
||||
CREATE TABLE timeline_events (
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(16) NOT NULL,
|
||||
event_date DATE NOT NULL,
|
||||
date_precision VARCHAR(16) NOT NULL DEFAULT 'YEAR',
|
||||
event_date_end DATE,
|
||||
description TEXT,
|
||||
created_by UUID NOT NULL,
|
||||
created_at TIMESTAMP,
|
||||
updated_by UUID NOT NULL,
|
||||
updated_at TIMESTAMP,
|
||||
version BIGINT,
|
||||
CONSTRAINT pk_timeline_events PRIMARY KEY (id),
|
||||
-- event_date_end is non-null IFF precision is RANGE (both directions).
|
||||
CONSTRAINT chk_timeline_event_range
|
||||
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL)),
|
||||
-- Curated events are never undated. Forbids exactly UNKNOWN — every other
|
||||
-- DatePrecision value (DAY, MONTH, SEASON, YEAR, RANGE, APPROX) stays legal.
|
||||
CONSTRAINT chk_timeline_event_precision
|
||||
CHECK (date_precision <> 'UNKNOWN')
|
||||
);
|
||||
|
||||
-- Join table: events ↔ persons involved.
|
||||
CREATE TABLE timeline_event_persons (
|
||||
timeline_event_id UUID NOT NULL,
|
||||
person_id UUID NOT NULL,
|
||||
CONSTRAINT pk_timeline_event_persons PRIMARY KEY (timeline_event_id, person_id),
|
||||
CONSTRAINT fk_tep_event
|
||||
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tep_person
|
||||
FOREIGN KEY (person_id) REFERENCES persons(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Join table: events ↔ supporting letters.
|
||||
CREATE TABLE timeline_event_documents (
|
||||
timeline_event_id UUID NOT NULL,
|
||||
document_id UUID NOT NULL,
|
||||
CONSTRAINT pk_timeline_event_documents PRIMARY KEY (timeline_event_id, document_id),
|
||||
CONSTRAINT fk_ted_event
|
||||
FOREIGN KEY (timeline_event_id) REFERENCES timeline_events(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_ted_document
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes added up-front (avoid the V62 FK-index retrofit debt): the two query columns plus
|
||||
-- the inverse-side FK columns. timeline_event_id needs no extra index on either join table —
|
||||
-- it is the leading column of the composite PK, so the PK index already serves those lookups.
|
||||
CREATE INDEX idx_timeline_events_event_date ON timeline_events (event_date);
|
||||
CREATE INDEX idx_timeline_events_type ON timeline_events (type);
|
||||
CREATE INDEX idx_timeline_event_persons_person_id ON timeline_event_persons (person_id);
|
||||
CREATE INDEX idx_timeline_event_documents_document_id ON timeline_event_documents (document_id);
|
||||
@@ -402,6 +402,7 @@ class DocumentControllerTest {
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void deleteDocument_returns204_whenHasWritePermission() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
|
||||
.delete("/api/documents/" + id).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
@@ -131,6 +131,28 @@ class DocumentLazyLoadingTest {
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_pureTextRelevance_doesNotThrowLazyInitializationException() {
|
||||
// q + default sort + no other filters → the relevance fast path
|
||||
// (relevanceSortedPageFromSql), which loads documents by id outside any
|
||||
// transaction and must still deliver an initialized tags collection.
|
||||
Person sender = savedPerson("Hans", "FtSender");
|
||||
Tag tag = savedTag("FtTag");
|
||||
savedDocument("Brief von Walter", "ft_doc.pdf", sender, Set.of(), Set.of(tag));
|
||||
|
||||
SearchFilters textOnly = new SearchFilters(
|
||||
"Walter", null, null, null, null, null, null, null, null, false);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
textOnly, null, "DESC", PageRequest.of(0, 10));
|
||||
|
||||
assertThat(result.totalElements()).isEqualTo(1);
|
||||
assertThatCode(() ->
|
||||
result.items().forEach(i -> i.tags().size()))
|
||||
.doesNotThrowAnyException();
|
||||
assertThat(result.items().getFirst().tags()).extracting(Tag::getName).containsExactly("FtTag");
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchDocuments_senderSort_doesNotThrowLazyInitializationException() {
|
||||
Person sender = savedPerson("Hans", "SsSender");
|
||||
|
||||
@@ -81,7 +81,7 @@ class DocumentServiceSortTest {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
List<Object[]> ftsRows = ftsRows(id1, 0.5d, 1L);
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any()))
|
||||
when(documentRepository.findByIdIn(any()))
|
||||
.thenReturn(List.of(doc(id1)));
|
||||
|
||||
documentService.searchDocuments(
|
||||
@@ -101,7 +101,7 @@ class DocumentServiceSortTest {
|
||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1))); // unordered from JPA
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
@@ -119,7 +119,7 @@ class DocumentServiceSortTest {
|
||||
ftsRows.add(new Object[]{id1, 0.8d, 2L});
|
||||
ftsRows.add(new Object[]{id2, 0.3d, 2L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(id2), doc(id1)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
@@ -153,7 +153,7 @@ class DocumentServiceSortTest {
|
||||
List<Object[]> ftsRows = new ArrayList<>();
|
||||
ftsRows.add(new Object[]{stringId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc(uuidId)));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc(uuidId)));
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
new SearchFilters("Brief", null, null, null, null, null, null, null, null, false),
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.filestorage.FileService;
|
||||
import org.raddatz.familienarchiv.tag.TagService;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
@@ -75,6 +76,7 @@ class DocumentServiceTest {
|
||||
@Mock AuditLogQueryService auditLogQueryService;
|
||||
@Mock TranscriptionBlockQueryService transcriptionBlockQueryService;
|
||||
@Mock ThumbnailAsyncRunner thumbnailAsyncRunner;
|
||||
@Mock ApplicationEventPublisher eventPublisher;
|
||||
// Real factory (pure, dependency-free) so save-time title-regeneration tests exercise the
|
||||
// shared composition rather than a stub — the #726 single source of truth.
|
||||
@Spy DocumentTitleFactory documentTitleFactory = new DocumentTitleFactory();
|
||||
@@ -87,7 +89,7 @@ class DocumentServiceTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.existsById(id)).thenReturn(true);
|
||||
|
||||
documentService.deleteDocument(id);
|
||||
documentService.deleteDocument(id, UUID.randomUUID());
|
||||
|
||||
verify(documentRepository).deleteById(id);
|
||||
}
|
||||
@@ -97,7 +99,7 @@ class DocumentServiceTest {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(documentRepository.existsById(id)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(id))
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(id, UUID.randomUUID()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining(id.toString());
|
||||
verify(documentRepository, never()).deleteById(any());
|
||||
@@ -2166,7 +2168,7 @@ class DocumentServiceTest {
|
||||
List<Object[]> ftsRows = new java.util.ArrayList<>();
|
||||
ftsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(ftsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
@@ -2202,7 +2204,7 @@ class DocumentServiceTest {
|
||||
List<Object[]> snippetFtsRows = new java.util.ArrayList<>();
|
||||
snippetFtsRows.add(new Object[]{docId, 0.5d, 1L});
|
||||
when(documentRepository.findFtsPageRaw(anyString(), anyInt(), anyInt())).thenReturn(snippetFtsRows);
|
||||
when(documentRepository.findAllById(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findByIdIn(any())).thenReturn(List.of(doc));
|
||||
when(documentRepository.findEnrichmentData(any(), eq("Brief"))).thenReturn(rows);
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Raw-SQL constraint tests for geschichten — deliberately NOT @Transactional at
|
||||
* class level (see JourneyItemConstraintsTest for the rationale).
|
||||
*
|
||||
* The V75 CHECK is the atomic backstop for GeschichteService.MAX_INTRO_LENGTH on
|
||||
* the verbatim JOURNEY intro write path. STORY bodies are intentionally exempt.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class GeschichteConstraintsTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
|
||||
private UUID insertGeschichte(String type, String body) {
|
||||
UUID id = UUID.randomUUID();
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO geschichten (id, title, body, status, type, created_at, updated_at) "
|
||||
+ "VALUES (?, ?, ?, 'DRAFT', ?, now(), now())",
|
||||
id, "Constraints-Test", body, type);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Test
|
||||
void journey_intro_check_rejects_4001_chars() {
|
||||
assertThatThrownBy(() -> insertGeschichte("JOURNEY", "x".repeat(4001)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void journey_intro_check_accepts_exactly_4000_chars() {
|
||||
UUID id = insertGeschichte("JOURNEY", "x".repeat(4000));
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void story_bodies_are_not_constrained_by_the_intro_check() {
|
||||
UUID id = insertGeschichte("STORY", "<p>" + "x".repeat(4001) + "</p>");
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM geschichten WHERE id = ?", Integer.class, id);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,13 @@ package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
@@ -21,22 +19,25 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.nullValue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
|
||||
@WebMvcTest(GeschichteController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
@@ -47,11 +48,9 @@ class GeschichteControllerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@MockitoBean
|
||||
GeschichteService geschichteService;
|
||||
|
||||
@MockitoBean
|
||||
CustomUserDetailsService customUserDetailsService;
|
||||
@MockitoBean GeschichteService geschichteService;
|
||||
@MockitoBean JourneyItemService journeyItemService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@@ -65,7 +64,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_returns200_forReader() throws Exception {
|
||||
when(geschichteService.list(any(), any(), any(), anyInt()))
|
||||
.thenReturn(List.of(published(UUID.randomUUID(), "Story A")));
|
||||
.thenReturn(List.of(summaryStub("Story A")));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -101,13 +100,50 @@ class GeschichteControllerTest {
|
||||
verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_passesDocumentIdFilterToService() throws Exception {
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(geschichteService.list(any(), any(), eq(documentId), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten").param("documentId", documentId.toString()))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(any(), any(), eq(documentId), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_passesLimitToService() throws Exception {
|
||||
when(geschichteService.list(any(), any(), any(), eq(5)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten").param("limit", "5"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(any(), any(), any(), eq(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void list_passesStatusFilterToService() throws Exception {
|
||||
when(geschichteService.list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/geschichten").param("status", "PUBLISHED"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(geschichteService).list(eq(GeschichteStatus.PUBLISHED), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns200_whenFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id)).thenReturn(published(id, "Hello"));
|
||||
when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
.andExpect(status().isOk())
|
||||
@@ -119,7 +155,7 @@ class GeschichteControllerTest {
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getById_returns404_whenServiceThrowsNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.getById(id))
|
||||
when(geschichteService.getView(id))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x"));
|
||||
|
||||
mockMvc.perform(get("/api/geschichten/{id}", id))
|
||||
@@ -151,7 +187,7 @@ class GeschichteControllerTest {
|
||||
void create_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.create(any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(draft(id, "New"));
|
||||
.thenReturn(viewStub(id, "New", GeschichteStatus.DRAFT));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("New");
|
||||
@@ -179,7 +215,7 @@ class GeschichteControllerTest {
|
||||
void update_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteService.update(eq(id), any(GeschichteUpdateDTO.class)))
|
||||
.thenReturn(published(id, "Updated"));
|
||||
.thenReturn(viewStub(id, "Updated", GeschichteStatus.PUBLISHED));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
@@ -208,31 +244,202 @@ class GeschichteControllerTest {
|
||||
verify(geschichteService).delete(id);
|
||||
}
|
||||
|
||||
// ─── POST /api/geschichten/{id}/items ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void appendItem_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void appendItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns201_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Note\"}"))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value(itemId.toString()))
|
||||
.andExpect(jsonPath("$.position").value(10));
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id}/items/{itemId} ──────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void updateItemNote_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, "Updated"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"Updated\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value("Updated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenReturn(itemViewStub(itemId, 10, null));
|
||||
|
||||
// Raw JSON — local objectMapper lacks JsonNullableModule
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\": null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.note").value(nullValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void updateItemNote_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.updateNote(eq(id), eq(itemId), any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"));
|
||||
|
||||
mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── DELETE /api/geschichten/{id}/items/{itemId} ─────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void deleteItem_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}",
|
||||
UUID.randomUUID(), UUID.randomUUID()).with(csrf()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns204_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(journeyItemService).delete(id, itemId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void deleteItem_returns404_whenItemNotFound() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found"))
|
||||
.when(journeyItemService).delete(id, itemId);
|
||||
|
||||
mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND"));
|
||||
}
|
||||
|
||||
// ─── PUT /api/geschichten/{id}/items/reorder ─────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void reorderItems_returns403_whenLackingBlogWrite() throws Exception {
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[]}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void reorderItems_returns200_withBlogWrite() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null)));
|
||||
|
||||
mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"itemIds\":[\"" + itemId + "\"]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(itemId.toString()));
|
||||
}
|
||||
|
||||
// ─── error mapping ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "BLOG_WRITE")
|
||||
void appendItem_returns409_on_position_conflict() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
when(journeyItemService.append(eq(id), any()))
|
||||
.thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict"));
|
||||
|
||||
mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"note\":\"x\"}"))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT"));
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte published(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.body("<p>x</p>")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.build();
|
||||
private JourneyItemView itemViewStub(UUID id, int position, String note) {
|
||||
return new JourneyItemView(id, position, null, note);
|
||||
}
|
||||
|
||||
private Geschichte draft(UUID id, String title) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title(title)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.updatedAt(LocalDateTime.now())
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.build();
|
||||
private GeschichteView viewStub(UUID id, String title) {
|
||||
return viewStub(id, title, GeschichteStatus.PUBLISHED);
|
||||
}
|
||||
|
||||
private GeschichteView viewStub(UUID id, String title, GeschichteStatus status) {
|
||||
return new GeschichteView(id, title, "<p>x</p>",
|
||||
status, GeschichteType.STORY,
|
||||
null, new HashSet<>(), List.of(),
|
||||
LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now());
|
||||
}
|
||||
|
||||
/** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */
|
||||
private GeschichteSummary summaryStub(String title) {
|
||||
return new GeschichteSummary() {
|
||||
public UUID getId() { return UUID.randomUUID(); }
|
||||
public String getTitle() { return title; }
|
||||
public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; }
|
||||
public GeschichteType getType() { return GeschichteType.STORY; }
|
||||
public AuthorSummary getAuthor() { return null; }
|
||||
public LocalDateTime getPublishedAt() { return LocalDateTime.now(); }
|
||||
public LocalDateTime getUpdatedAt() { return LocalDateTime.now(); }
|
||||
public String getBody() { return null; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.user.UserGroup;
|
||||
import org.raddatz.familienarchiv.user.UserGroupRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.ClientHttpResponse;
|
||||
import org.springframework.http.client.JdkClientHttpRequestFactory;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.web.client.DefaultResponseErrorHandler;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer.
|
||||
*
|
||||
* <p>No {@code @Transactional} at class level — that would keep a session open and
|
||||
* mask LazyInitializationException caused by open-in-view: false. Each test seeds data
|
||||
* directly via repositories and relies on the service's own transaction boundaries.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class GeschichteHttpTest {
|
||||
|
||||
@LocalServerPort int port;
|
||||
@MockitoBean S3Client s3Client;
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired UserGroupRepository userGroupRepository;
|
||||
@Autowired PasswordEncoder passwordEncoder;
|
||||
|
||||
private RestTemplate http;
|
||||
private String baseUrl;
|
||||
|
||||
private static final String WRITER_EMAIL = "geschichten-http-writer@test.de";
|
||||
private static final String WRITER_PASSWORD = "pass!Geschichte1";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
http = noThrowRestTemplate();
|
||||
baseUrl = "http://localhost:" + port;
|
||||
geschichteRepository.deleteAll();
|
||||
appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||
appUserRepository.findByEmail(BLOG_WRITER_EMAIL).ifPresent(appUserRepository::delete);
|
||||
userGroupRepository.findByName("HttpTest-BlogWriters").ifPresent(userGroupRepository::delete);
|
||||
appUserRepository.save(AppUser.builder()
|
||||
.email(WRITER_EMAIL)
|
||||
.password(passwordEncoder.encode(WRITER_PASSWORD))
|
||||
.build());
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void list_returns_200_and_empty_array_when_no_stories_exist() {
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).isEqualTo("[]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_returns_200_and_does_not_500_when_stories_have_journey_items() {
|
||||
// Seed a JOURNEY directly — items are LAZY; without @Transactional(readOnly=true) +
|
||||
// Hibernate.initialize in getById() this would 500. list() uses a projection so it
|
||||
// must also never touch items.
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Reise durch die Briefe")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(journey)
|
||||
.position(1000)
|
||||
.note("Einleitung")
|
||||
.build();
|
||||
journey.getItems().add(item);
|
||||
geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten", HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).contains("Reise durch die Briefe");
|
||||
}
|
||||
|
||||
// ─── GET /api/geschichten/{id} ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getById_returns_200_with_items_and_does_not_500_open_in_view_false() {
|
||||
// This test is the canonical guard against LazyInitializationException.
|
||||
// open-in-view: false means the Hibernate session is closed when Jackson serializes.
|
||||
// GeschichteService.getById() must initialize items inside its @Transactional boundary.
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Familiengeschichte")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
JourneyItem note = JourneyItem.builder()
|
||||
.geschichte(journey).position(1000).note("Prolog").build();
|
||||
JourneyItem note2 = JourneyItem.builder()
|
||||
.geschichte(journey).position(2000).note("Epilog").build();
|
||||
journey.getItems().add(note);
|
||||
journey.getItems().add(note2);
|
||||
Geschichte saved = geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("Familiengeschichte")
|
||||
.contains("Prolog")
|
||||
.contains("Epilog");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returns_404_for_unknown_id() {
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + UUID.randomUUID(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
assertThat(response.getBody()).contains("GESCHICHTE_NOT_FOUND");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getById_returns_404_for_draft_when_reader_lacks_BLOG_WRITE() {
|
||||
AppUser writer = appUserRepository.findByEmail(WRITER_EMAIL).orElseThrow();
|
||||
Geschichte draft = Geschichte.builder()
|
||||
.title("Geheimer Entwurf")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.author(writer)
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
Geschichte saved = geschichteRepository.save(draft);
|
||||
|
||||
// Writer lacks explicit BLOG_WRITE permission in the app_users table,
|
||||
// so from the service's perspective they're a reader.
|
||||
String session = loginAsWriter();
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET,
|
||||
new HttpEntity<>(sessionHeaders(session)), String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(404);
|
||||
}
|
||||
|
||||
// ─── PATCH /api/geschichten/{id} ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void update_returns_200_and_serializes_items_open_in_view_false() {
|
||||
// Canonical guard for the write path: PATCH must not 500 when the response
|
||||
// is serialized after the service transaction closed. The raw entity carries
|
||||
// a dead lazy items proxy at that point — the endpoint must answer with a
|
||||
// view assembled inside the transaction.
|
||||
AppUser writer = blogWriter();
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Reise vor dem Umbenennen")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.items(new ArrayList<>())
|
||||
.persons(new HashSet<>())
|
||||
.build();
|
||||
journey.getItems().add(JourneyItem.builder()
|
||||
.geschichte(journey).position(1000).note("Prolog").build());
|
||||
Geschichte saved = geschichteRepository.save(journey);
|
||||
|
||||
String session = loginAs(BLOG_WRITER_EMAIL, BLOG_WRITER_PASSWORD);
|
||||
ResponseEntity<String> response = http.exchange(
|
||||
baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.PATCH,
|
||||
new HttpEntity<>("{\"title\":\"Reise nach dem Umbenennen\"}", csrfJsonHeaders(session)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody())
|
||||
.contains("Reise nach dem Umbenennen")
|
||||
.contains("Prolog");
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static final String BLOG_WRITER_EMAIL = "geschichten-http-blogwriter@test.de";
|
||||
private static final String BLOG_WRITER_PASSWORD = "pass!Geschichte2";
|
||||
|
||||
/** A user whose group actually grants BLOG_WRITE — unlike the plain writer above. */
|
||||
private AppUser blogWriter() {
|
||||
UserGroup group = userGroupRepository.save(UserGroup.builder()
|
||||
.name("HttpTest-BlogWriters")
|
||||
.permissions(new HashSet<>(Set.of("BLOG_WRITE")))
|
||||
.build());
|
||||
return appUserRepository.save(AppUser.builder()
|
||||
.email(BLOG_WRITER_EMAIL)
|
||||
.password(passwordEncoder.encode(BLOG_WRITER_PASSWORD))
|
||||
.groups(new HashSet<>(Set.of(group)))
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Session cookie + double-submit CSRF pair + JSON content type for write requests. */
|
||||
private HttpHeaders csrfJsonHeaders(String sessionId) {
|
||||
String xsrf = UUID.randomUUID().toString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId + "; XSRF-TOKEN=" + xsrf);
|
||||
headers.set("X-XSRF-TOKEN", xsrf);
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String loginAsWriter() {
|
||||
return loginAs(WRITER_EMAIL, WRITER_PASSWORD);
|
||||
}
|
||||
|
||||
private String loginAs(String email, String password) {
|
||||
String xsrf = UUID.randomUUID().toString();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Cookie", "XSRF-TOKEN=" + xsrf);
|
||||
headers.set("X-XSRF-TOKEN", xsrf);
|
||||
String body = "{\"email\":\"" + email + "\",\"password\":\"" + password + "\"}";
|
||||
ResponseEntity<String> resp = http.postForEntity(
|
||||
baseUrl + "/api/auth/login", new HttpEntity<>(body, headers), String.class);
|
||||
return extractFaSessionCookie(resp);
|
||||
}
|
||||
|
||||
private HttpHeaders sessionHeaders(String sessionId) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Cookie", "fa_session=" + sessionId);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private String extractFaSessionCookie(ResponseEntity<?> response) {
|
||||
List<String> setCookieHeader = response.getHeaders().get("Set-Cookie");
|
||||
if (setCookieHeader == null) return "";
|
||||
return setCookieHeader.stream()
|
||||
.filter(c -> c.startsWith("fa_session="))
|
||||
.map(c -> c.split(";")[0].substring("fa_session=".length()))
|
||||
.findFirst()
|
||||
.orElse("");
|
||||
}
|
||||
|
||||
private RestTemplate noThrowRestTemplate() {
|
||||
// JDK HttpClient factory — the default HttpURLConnection factory cannot send PATCH.
|
||||
RestTemplate template = new RestTemplate(new JdkClientHttpRequestFactory());
|
||||
template.setErrorHandler(new DefaultResponseErrorHandler() {
|
||||
@Override
|
||||
public boolean hasError(ClientHttpResponse response) throws IOException {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem;
|
||||
import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemRepository;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class GeschichteListProjectionTest {
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
|
||||
AppUser author;
|
||||
AppUser otherAuthor;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
geschichteRepository.deleteAll();
|
||||
author = appUserRepository.save(AppUser.builder()
|
||||
.email("author@test").password("pw").build());
|
||||
otherAuthor = appUserRepository.save(AppUser.builder()
|
||||
.email("other@test").password("pw").build());
|
||||
}
|
||||
|
||||
// ─── findSummaries returns only the requested status ─────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() {
|
||||
geschichteRepository.save(published("Veröffentlicht", author));
|
||||
geschichteRepository.save(draft("Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_carries_updatedAt_for_dashboard_relative_times() {
|
||||
// ReaderDraftsModule renders "bearbeitet vor X" from updatedAt — the
|
||||
// projection must carry it for drafts, where publishedAt is null.
|
||||
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getUpdatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_returns_empty_list_when_no_published_geschichten_exist() {
|
||||
geschichteRepository.save(draft("Nur Entwurf", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── AuthorSummary nested projection ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_exposes_nested_author_names_but_never_email() {
|
||||
AppUser richAuthor = appUserRepository.save(AppUser.builder()
|
||||
.firstName("Franz").lastName("Raddatz")
|
||||
.email("franz@raddatz.de").password("pw").build());
|
||||
geschichteRepository.save(published("Briefe aus der Front", richAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
GeschichteSummary.AuthorSummary a = result.get(0).getAuthor();
|
||||
assertThat(a.getFirstName()).isEqualTo("Franz");
|
||||
assertThat(a.getLastName()).isEqualTo("Raddatz");
|
||||
// Design rule (GeschichteView.AuthorView javadoc): author projections never
|
||||
// expose email or group memberships to readers.
|
||||
assertThat(GeschichteSummary.AuthorSummary.class.getMethods())
|
||||
.extracting(java.lang.reflect.Method::getName)
|
||||
.doesNotContain("getEmail");
|
||||
}
|
||||
|
||||
// ─── GeschichteType is exposed ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_exposes_type_field() {
|
||||
Geschichte journey = Geschichte.builder()
|
||||
.title("Eine Reise")
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(author)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
geschichteRepository.save(journey);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
// ─── authorId filter (own-drafts gate) ───────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_authorId_returns_only_own_drafts() {
|
||||
geschichteRepository.save(draft("Mein Entwurf", author));
|
||||
geschichteRepository.save(draft("Fremder Entwurf", otherAuthor));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.DRAFT, author.getId(), sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf");
|
||||
}
|
||||
|
||||
// ─── personCount = 0 → no person filter ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() {
|
||||
geschichteRepository.save(published("A", author));
|
||||
geschichteRepository.save(published("B", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, null);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
}
|
||||
|
||||
// ─── personCount > 0 AND-semantics ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_one_personId_returns_only_linked_stories() {
|
||||
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||
|
||||
Geschichte withFranz = published("Franz story", author);
|
||||
withFranz.getPersons().add(franz);
|
||||
geschichteRepository.save(withFranz);
|
||||
|
||||
Geschichte withAnna = published("Anna story", author);
|
||||
withAnna.getPersons().add(anna);
|
||||
geschichteRepository.save(withAnna);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Franz story");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_with_two_personIds_uses_AND_semantics() {
|
||||
Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build());
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build());
|
||||
|
||||
Geschichte both = published("Both", author);
|
||||
both.getPersons().add(franz);
|
||||
both.getPersons().add(anna);
|
||||
geschichteRepository.save(both);
|
||||
|
||||
Geschichte onlyFranz = published("Only Franz", author);
|
||||
onlyFranz.getPersons().add(franz);
|
||||
geschichteRepository.save(onlyFranz);
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2, null);
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Both");
|
||||
}
|
||||
|
||||
// ─── documentId filter (JPQL EXISTS subquery) ────────────────────────────
|
||||
|
||||
@Test
|
||||
void findSummaries_with_documentId_returns_journey_containing_that_document() {
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
Geschichte withDoc = geschichteRepository.save(journey("Reise mit Dokument", author));
|
||||
Geschichte withoutDoc = geschichteRepository.save(journey("Reise ohne Dokument", author));
|
||||
journeyItemRepository.save(JourneyItem.builder()
|
||||
.geschichte(withDoc).document(doc).position(1).build());
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, doc.getId());
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
assertThat(result.get(0).getTitle()).isEqualTo("Reise mit Dokument");
|
||||
assertThat(result).extracting(GeschichteSummary::getTitle).doesNotContain("Reise ohne Dokument");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findSummaries_with_unknown_documentId_returns_empty() {
|
||||
geschichteRepository.save(journey("Irgendeine Reise", author));
|
||||
|
||||
List<GeschichteSummary> result = geschichteRepository.findSummaries(
|
||||
GeschichteStatus.PUBLISHED, null, sentinel(), 0, UUID.randomUUID());
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte published(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte draft(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.author(writer)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte journey(String title, AppUser writer) {
|
||||
return Geschichte.builder()
|
||||
.title(title)
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.author(writer)
|
||||
.publishedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */
|
||||
private List<UUID> sentinel() {
|
||||
return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -76,11 +80,11 @@ class GeschichteServiceIntegrationTest {
|
||||
+ "<script>alert('xss')</script>");
|
||||
dto.setPersonIds(List.of(franz.getId()));
|
||||
|
||||
Geschichte created = geschichteService.create(dto);
|
||||
GeschichteView created = geschichteService.create(dto);
|
||||
|
||||
assertThat(created.getId()).isNotNull();
|
||||
assertThat(created.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.getBody())
|
||||
assertThat(created.id()).isNotNull();
|
||||
assertThat(created.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(created.body())
|
||||
.contains("<strong>jeden Sonntag</strong>")
|
||||
.doesNotContain("<script>");
|
||||
|
||||
@@ -89,7 +93,7 @@ class GeschichteServiceIntegrationTest {
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty();
|
||||
|
||||
// Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND)
|
||||
UUID draftId = created.getId();
|
||||
UUID draftId = created.id();
|
||||
org.assertj.core.api.Assertions.assertThatThrownBy(() -> geschichteService.getById(draftId))
|
||||
.hasMessageContaining("not found");
|
||||
|
||||
@@ -97,16 +101,17 @@ class GeschichteServiceIntegrationTest {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
GeschichteUpdateDTO publishDto = new GeschichteUpdateDTO();
|
||||
publishDto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
Geschichte publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.getPublishedAt()).isNotNull();
|
||||
GeschichteView publishedGesch = geschichteService.update(draftId, publishDto);
|
||||
assertThat(publishedGesch.publishedAt()).isNotNull();
|
||||
|
||||
// Reader can now see and fetch it
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1);
|
||||
assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1);
|
||||
Geschichte fetched = geschichteService.getById(draftId);
|
||||
assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId());
|
||||
GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId));
|
||||
assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz");
|
||||
assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId());
|
||||
|
||||
// Delete as writer; join rows go with it
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
@@ -137,17 +142,17 @@ class GeschichteServiceIntegrationTest {
|
||||
|
||||
// No filter → all three
|
||||
assertThat(geschichteService.list(null, List.of(), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// Single filter (Anna) → all three
|
||||
assertThat(geschichteService.list(null, List.of(a.getId()), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactlyInAnyOrder(storyAB, storyAC, storyA);
|
||||
|
||||
// AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC)
|
||||
assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50))
|
||||
.extracting(Geschichte::getId)
|
||||
.extracting(GeschichteSummary::getId)
|
||||
.containsExactly(storyAB);
|
||||
|
||||
// AND: Bertha AND Carl → none (no story has both)
|
||||
@@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest {
|
||||
geschichteService.create(dto);
|
||||
|
||||
authenticateAs(writer2, Permission.BLOG_WRITE);
|
||||
List<Geschichte> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
List<GeschichteSummary> result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
@@ -185,7 +190,7 @@ class GeschichteServiceIntegrationTest {
|
||||
dto.setBody("<p>body</p>");
|
||||
dto.setPersonIds(personIds);
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
return geschichteService.create(dto).getId();
|
||||
return geschichteService.create(dto).id();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
|
||||
@@ -2,31 +2,28 @@ package org.raddatz.familienarchiv.geschichte;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
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.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 +34,11 @@ import java.util.stream.Collectors;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
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 +46,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 +93,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 +106,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,83 +122,207 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ─── getView ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getView_returns_assembled_view_and_delegates_to_journeyItemService() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note");
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(published));
|
||||
when(journeyItemService.getItems(id)).thenReturn(List.of(item));
|
||||
|
||||
GeschichteView view = geschichteService.getView(id);
|
||||
|
||||
assertThat(view.id()).isEqualTo(id);
|
||||
assertThat(view.items()).containsExactly(item);
|
||||
verify(journeyItemService).getItems(id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getView_throws_NOT_FOUND_when_id_unknown() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.getView(id))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_uses_firstName_lastName() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("author@test")
|
||||
.firstName("Hans").lastName("Raddatz").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("Hans Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("anon@test").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.author().displayName()).isEqualTo("[Unbekannt]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_author_email_is_not_in_author_view() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setAuthor(AppUser.builder()
|
||||
.id(UUID.randomUUID()).email("secret@test")
|
||||
.firstName("Max").lastName("M").build());
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
// AuthorView exposes only id + displayName — no email field at all
|
||||
assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class);
|
||||
assertThat(result.author().displayName()).doesNotContain("secret@test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_persons_are_mapped_to_PersonView() {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID personId = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
published.setPersons(new HashSet<>(List.of(
|
||||
Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build()
|
||||
)));
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.persons()).hasSize(1);
|
||||
GeschichteView.PersonView pv = result.persons().iterator().next();
|
||||
assertThat(pv.id()).isEqualTo(personId);
|
||||
assertThat(pv.firstName()).isEqualTo("Franz");
|
||||
assertThat(pv.lastName()).isEqualTo("Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toView_items_are_passed_through() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte published = published(id);
|
||||
|
||||
GeschichteView result = geschichteService.toView(published, List.of());
|
||||
|
||||
assertThat(result.items()).isEmpty();
|
||||
}
|
||||
|
||||
// ─── list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50);
|
||||
geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
// Status pinning lives inside the Specification; we assert end-to-end behaviour
|
||||
// in GeschichteServiceIntegrationTest. Here we just confirm the service routes
|
||||
// through the spec-aware repository method.
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID())));
|
||||
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
assertThat(out).hasSize(2);
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findAll_when_filtering_by_single_personId() {
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_single_personId() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(personId), null, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() {
|
||||
void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID a = UUID.randomUUID();
|
||||
UUID b = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(a, b), null, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_filters_by_documentId() {
|
||||
void list_passes_documentId_to_repository_as_journey_item_filter() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
UUID documentId = UUID.randomUUID();
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(), documentId, 50);
|
||||
|
||||
verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class));
|
||||
verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong(), eq(documentId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() {
|
||||
void list_passes_nil_uuid_sentinel_to_repository_when_no_person_filter_given() {
|
||||
// B2: when personIds is empty/null the service must pass a sentinel NIL UUID
|
||||
// so the IN() predicate is skipped without producing invalid empty-IN() SQL.
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findAll(any(Specification.class), any(Sort.class)))
|
||||
.thenReturn(List.of(published(UUID.randomUUID())));
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
// 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query
|
||||
List<Geschichte> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
UUID nilUUID = UUID.fromString("00000000-0000-0000-0000-000000000000");
|
||||
verify(geschichteRepository).findSummaries(
|
||||
any(), any(), org.mockito.ArgumentMatchers.argThat(ids -> ids.contains(nilUUID)), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void list_caps_limit_at_max_when_caller_passes_huge_value() {
|
||||
authenticateAs(reader, Permission.READ_ALL);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of(mock(GeschichteSummary.class)));
|
||||
|
||||
List<GeschichteSummary> out = geschichteService.list(null, List.of(), null, 9999);
|
||||
|
||||
assertThat(out).hasSizeLessThanOrEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("security: null status for blog writer returns PUBLISHED, never leaks drafts")
|
||||
void list_with_blog_writer_and_null_status_returns_PUBLISHED_not_all_drafts() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(null, List.of(), null, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(
|
||||
eq(GeschichteStatus.PUBLISHED), isNull(), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("security: DRAFT status scopes to current user only")
|
||||
void list_with_DRAFT_status_scopes_to_current_user_not_all_authors() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.findSummaries(any(), any(), any(), anyLong(), any()))
|
||||
.thenReturn(List.of());
|
||||
|
||||
geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50);
|
||||
|
||||
verify(geschichteRepository).findSummaries(
|
||||
eq(GeschichteStatus.DRAFT), eq(writer.getId()), any(), anyLong(), any());
|
||||
}
|
||||
|
||||
// ─── create ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -213,11 +336,11 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("My Story");
|
||||
dto.setBody("<p>plain text</p>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
assertThat(saved.getAuthor()).isSameAs(writer);
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
assertThat(saved.author().id()).isEqualTo(writer.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -231,9 +354,9 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("XSS attempt");
|
||||
dto.setBody("<p>safe</p><script>alert(1)</script><img src=x onerror=alert(2)>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getBody())
|
||||
assertThat(saved.body())
|
||||
.contains("<p>safe</p>")
|
||||
.doesNotContain("<script>")
|
||||
.doesNotContain("onerror")
|
||||
@@ -252,9 +375,9 @@ class GeschichteServiceTest {
|
||||
dto.setBody("<h2>Heading</h2><p>Some <strong>bold</strong> and <em>italic</em>.</p>"
|
||||
+ "<ul><li>one</li></ul><ol><li>first</li></ol>");
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getBody())
|
||||
assertThat(saved.body())
|
||||
.contains("<h2>Heading</h2>")
|
||||
.contains("<strong>bold</strong>")
|
||||
.contains("<em>italic</em>")
|
||||
@@ -277,28 +400,9 @@ class GeschichteServiceTest {
|
||||
dto.setTitle("Linked");
|
||||
dto.setPersonIds(List.of(personId));
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getPersons()).containsExactly(person);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_resolves_documentIds_via_DocumentService() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
UUID docId = UUID.randomUUID();
|
||||
Document doc = Document.builder().id(docId).build();
|
||||
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Linked doc");
|
||||
dto.setDocumentIds(List.of(docId));
|
||||
|
||||
Geschichte saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.getDocuments()).containsExactly(doc);
|
||||
assertThat(saved.persons()).extracting(GeschichteView.PersonView::id).containsExactly(personId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -315,6 +419,202 @@ class GeschichteServiceTest {
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_preserves_JOURNEY_type_from_dto() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Journey");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_defaults_to_STORY_when_type_is_null() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("My Story");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||
// The journey intro is plain text: JourneyReader renders it via Svelte text
|
||||
// interpolation (never {@html}), so the OWASP sanitizer's entity encoding
|
||||
// would corrupt real content ("Müller & Söhne" → "Müller & Söhne") and
|
||||
// re-encode cumulatively on every editor round-trip.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("Müller & Söhne, Temperatur < 0");
|
||||
|
||||
GeschichteView saved = geschichteService.create(dto);
|
||||
|
||||
assertThat(saved.body()).isEqualTo("Müller & Söhne, Temperatur < 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_stores_JOURNEY_intro_verbatim_without_html_entity_encoding() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.JOURNEY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("Temperatur < 0 & Schnee");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.body()).isEqualTo("Temperatur < 0 & Schnee");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_still_sanitizes_STORY_body() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
}
|
||||
|
||||
// ─── length caps ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void create_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("x".repeat(256));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_accepts_title_of_exactly_255_chars() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("x".repeat(255));
|
||||
|
||||
assertThat(geschichteService.create(dto).title()).hasSize(255);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_rejects_title_longer_than_255_with_GESCHICHTE_TITLE_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(draft(id)));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("x".repeat(256));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TITLE_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("x".repeat(4001));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.create(dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_accepts_JOURNEY_intro_of_exactly_4000_chars() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
when(userService.findByEmail(writer.getEmail())).thenReturn(writer);
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setTitle("Winterbriefe");
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
dto.setBody("x".repeat(4000));
|
||||
|
||||
assertThat(geschichteService.create(dto).body()).hasSize(4000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_rejects_JOURNEY_intro_longer_than_4000_with_GESCHICHTE_INTRO_TOO_LONG() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.JOURNEY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("x".repeat(4001));
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_INTRO_TOO_LONG);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_does_not_apply_the_intro_cap_to_STORY_bodies() {
|
||||
// STORY bodies are sanitized Tiptap HTML and intentionally unbounded —
|
||||
// the 4000-char cap exists for the verbatim JOURNEY intro path only.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>" + "x".repeat(4001) + "</p>");
|
||||
|
||||
assertThat(geschichteService.update(id, dto).body()).contains("<p>");
|
||||
}
|
||||
|
||||
// ─── update ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -330,10 +630,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.PUBLISHED);
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.getPublishedAt()).isNotNull();
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.PUBLISHED);
|
||||
assertThat(saved.publishedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -349,10 +649,10 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setStatus(GeschichteStatus.DRAFT);
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getStatus()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.getPublishedAt()).isNull();
|
||||
assertThat(saved.status()).isEqualTo(GeschichteStatus.DRAFT);
|
||||
assertThat(saved.publishedAt()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -366,9 +666,46 @@ class GeschichteServiceTest {
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setBody("<p>ok</p><script>alert(1)</script>");
|
||||
|
||||
Geschichte saved = geschichteService.update(id, dto);
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.getBody()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
assertThat(saved.body()).doesNotContain("<script>").contains("<p>ok</p>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_rejects_type_change_with_409_GESCHICHTE_TYPE_IMMUTABLE() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setType(GeschichteType.JOURNEY);
|
||||
|
||||
assertThatThrownBy(() -> geschichteService.update(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting("code")
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void update_accepts_dto_carrying_the_unchanged_type() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
UUID id = UUID.randomUUID();
|
||||
Geschichte existing = draft(id);
|
||||
existing.setType(GeschichteType.STORY);
|
||||
when(geschichteRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||
when(geschichteRepository.save(any(Geschichte.class)))
|
||||
.thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
GeschichteUpdateDTO dto = new GeschichteUpdateDTO();
|
||||
dto.setType(GeschichteType.STORY);
|
||||
dto.setTitle("Unverändert getypt");
|
||||
|
||||
GeschichteView saved = geschichteService.update(id, dto);
|
||||
|
||||
assertThat(saved.type()).isEqualTo(GeschichteType.STORY);
|
||||
assertThat(saved.title()).isEqualTo("Unverändert getypt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -426,7 +763,7 @@ class GeschichteServiceTest {
|
||||
.body("<p>body</p>")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -438,7 +775,7 @@ class GeschichteServiceTest {
|
||||
.status(GeschichteStatus.PUBLISHED)
|
||||
.publishedAt(LocalDateTime.now().minusHours(1))
|
||||
.persons(new HashSet<>())
|
||||
.documents(new HashSet<>())
|
||||
.items(new ArrayList<>())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level.
|
||||
* A DataIntegrityViolationException inside a class-level @Transactional marks the tx
|
||||
* rollback-only and cascades into TransactionSystemException on teardown.
|
||||
* Each test inserts via jdbcTemplate and uses explicit SQL teardown.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class JourneyItemConstraintsTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
|
||||
private UUID geschichteId;
|
||||
private UUID documentId;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbcTemplate.execute("DELETE FROM journey_items");
|
||||
Document doc = documentRepository.save(Document.builder()
|
||||
.title("Constraints-Test-Doc")
|
||||
.originalFilename("ct.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
documentId = doc.getId();
|
||||
Geschichte g = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Constraints-Test-Journey")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
geschichteId = g.getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_is_deferrable_initially_deferred() {
|
||||
Boolean condeferrable = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
Boolean condeferred = jdbcTemplate.queryForObject(
|
||||
"SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'",
|
||||
Boolean.class);
|
||||
assertThat(condeferrable).as("constraint must be deferrable").isTrue();
|
||||
assertThat(condeferred).as("constraint must be initially deferred").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_index_rejects_duplicate_document_per_geschichte() {
|
||||
// Atomic backstop for the service-level dedup pre-check (check-then-insert race).
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 20, documentId))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_index_allows_same_document_in_different_journeys() {
|
||||
Geschichte other = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Andere Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), other.getId(), 10, documentId);
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM journey_items WHERE document_id = ?", Integer.class, documentId);
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_index_allows_multiple_note_only_items() {
|
||||
// document_id IS NULL rows must not collide — the index is partial.
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, "erste Notiz");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 20, "zweite Notiz");
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
|
||||
assertThat(count).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void note_length_check_rejects_2001_chars() {
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, "x".repeat(2001)))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void note_length_check_accepts_exactly_2000_chars() {
|
||||
// Pins the boundary at the DB layer too — a future <= vs < migration edit
|
||||
// must fail here, not only in the mock-based service test.
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, "x".repeat(2000));
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM journey_items WHERE geschichte_id = ?", Integer.class, geschichteId);
|
||||
assertThat(count).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void position_check_rejects_nonpositive() {
|
||||
UUID itemId = UUID.randomUUID();
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)",
|
||||
itemId, geschichteId, 0, "test"))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unique_constraint_rejects_duplicate_position_per_geschichte() {
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId);
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)",
|
||||
UUID.randomUUID(), geschichteId, 10, documentId))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.reset;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
class JourneyItemDocumentDeleteTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@MockitoBean
|
||||
AuditService auditService;
|
||||
|
||||
@MockitoSpyBean
|
||||
DocumentRepository documentRepository;
|
||||
|
||||
@PersistenceContext
|
||||
EntityManager em;
|
||||
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired DocumentRepository docRepo;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
@Autowired JdbcTemplate jdbcTemplate;
|
||||
|
||||
Geschichte journey;
|
||||
Document doc;
|
||||
AppUser writer;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
writer = appUserRepository.save(AppUser.builder()
|
||||
.email("delete-test-writer@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
doc = docRepo.save(Document.builder()
|
||||
.title("Testbrief")
|
||||
.originalFilename("testbrief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
journey = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(writer.getEmail(), null,
|
||||
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
SecurityContextHolder.clearContext();
|
||||
reset(documentRepository);
|
||||
// Deletion order is FK-load-bearing: journey_items reference both documents
|
||||
// and geschichten, so children must be removed before their parents.
|
||||
journeyItemRepository.deleteAll();
|
||||
docRepo.deleteAll();
|
||||
geschichteRepository.deleteAll();
|
||||
appUserRepository.deleteAll();
|
||||
}
|
||||
|
||||
// ─── AC-1: headline ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_linked_via_note_less_item_deletes_item_not_500() {
|
||||
JourneyItem item = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
|
||||
assertThat(journeyItemRepository.findById(item.getId())).isEmpty();
|
||||
assertThat(docRepo.findById(doc.getId())).isEmpty();
|
||||
}
|
||||
|
||||
// ─── AC-2: note-carrying item survives as placeholder ─────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_preserves_note_carrying_item_as_placeholder() {
|
||||
JourneyItem item = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).note("curator context").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
JourneyItem surviving = journeyItemRepository.findById(item.getId()).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("curator context");
|
||||
}
|
||||
|
||||
// ─── AC-3: note-only item untouched ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_does_not_affect_note_only_item() {
|
||||
JourneyItem noteOnly = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).note("Einleitung").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
JourneyItem reloaded = journeyItemRepository.findById(noteOnly.getId()).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isNull();
|
||||
assertThat(reloaded.getNote()).isEqualTo("Einleitung");
|
||||
}
|
||||
|
||||
// ─── AC-4: asymmetric multi-journey ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_applies_independently_per_referencing_item() {
|
||||
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Zweite Reise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
|
||||
JourneyItem noteLess = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
JourneyItem noteCarrying = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey2).position(10).document(doc).note("Begleittext").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemRepository.findById(noteLess.getId())).isEmpty();
|
||||
JourneyItem surviving = journeyItemRepository.findById(noteCarrying.getId()).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("Begleittext");
|
||||
}
|
||||
|
||||
// ─── AC-5: rollback guard ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void listener_deletes_roll_back_when_document_delete_fails() {
|
||||
JourneyItem item = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
em.clear();
|
||||
|
||||
doThrow(new RuntimeException("simulated failure"))
|
||||
.when(documentRepository).deleteById(any());
|
||||
|
||||
assertThatThrownBy(() -> documentService.deleteDocument(doc.getId(), writer.getId()))
|
||||
.isInstanceOf(RuntimeException.class);
|
||||
|
||||
em.clear();
|
||||
assertThat(journeyItemRepository.findById(item.getId())).isPresent();
|
||||
}
|
||||
|
||||
// ─── AC-6: empty-string note boundary ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void empty_string_note_item_is_cascaded_whitespace_only_note_is_preserved() {
|
||||
// uq_journey_items_geschichte_document prevents two items with the same
|
||||
// (geschichte_id, document_id) in one journey — use two separate journeys.
|
||||
Geschichte journey2 = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Zweite Reise für AC-6")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
|
||||
UUID emptyNoteItemId = UUID.randomUUID();
|
||||
UUID whitespaceNoteItemId = UUID.randomUUID();
|
||||
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
|
||||
emptyNoteItemId, journey.getId(), 10, doc.getId(), "");
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO journey_items (id, geschichte_id, position, document_id, note) VALUES (?,?,?,?,?)",
|
||||
whitespaceNoteItemId, journey2.getId(), 20, doc.getId(), " ");
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemRepository.findById(emptyNoteItemId)).isEmpty();
|
||||
JourneyItem whitespaceItem = journeyItemRepository.findById(whitespaceNoteItemId).orElseThrow();
|
||||
assertThat(whitespaceItem.getDocumentId()).isNull();
|
||||
assertThat(whitespaceItem.getNote()).isEqualTo(" ");
|
||||
}
|
||||
|
||||
// ─── Idempotency / no-collateral ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_in_zero_journeys_returns_no_collateral() {
|
||||
Document unlinked = docRepo.save(Document.builder()
|
||||
.title("Unverknüpfter Brief")
|
||||
.originalFilename("other.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
JourneyItem unrelated = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).note("unrelated note").build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(unlinked.getId(), writer.getId());
|
||||
em.clear();
|
||||
|
||||
assertThat(docRepo.findById(unlinked.getId())).isEmpty();
|
||||
assertThat(journeyItemRepository.findById(unrelated.getId())).isPresent();
|
||||
assertThat(journeyItemRepository.count()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ─── AC-7: audit — DOCUMENT_DELETED emitted, JOURNEY_ITEM_REMOVED absent ─
|
||||
|
||||
@Test
|
||||
void deleting_document_emits_document_audit_but_no_journey_item_audit() {
|
||||
journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(journey).position(10).document(doc).build());
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.DOCUMENT_DELETED), eq(writer.getId()), eq(doc.getId()), any());
|
||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), any(), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.AppUserRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class JourneyItemIntegrationTest {
|
||||
|
||||
@MockitoBean
|
||||
S3Client s3Client;
|
||||
|
||||
@MockitoBean
|
||||
AuditService auditService;
|
||||
|
||||
@PersistenceContext
|
||||
EntityManager em;
|
||||
|
||||
@Autowired GeschichteRepository geschichteRepository;
|
||||
@Autowired JourneyItemRepository journeyItemRepository;
|
||||
@Autowired JourneyItemService journeyItemService;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired AppUserRepository appUserRepository;
|
||||
|
||||
Geschichte journey;
|
||||
Document doc;
|
||||
AppUser writer;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
writer = appUserRepository.save(AppUser.builder()
|
||||
.email("journey-writer@test")
|
||||
.password("hash")
|
||||
.build());
|
||||
doc = documentRepository.save(Document.builder()
|
||||
.title("Testbrief")
|
||||
.originalFilename("testbrief.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.build());
|
||||
journey = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Lesereise")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearSecurity() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(AppUser user, Permission... permissions) {
|
||||
var authorities = java.util.Arrays.stream(permissions)
|
||||
.map(p -> new SimpleGrantedAuthority(p.name()))
|
||||
.toList();
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities));
|
||||
}
|
||||
|
||||
// ─── @OrderBy ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void items_are_returned_in_position_order_regardless_of_insertion_order() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
|
||||
// Distinct content per item — V74's partial unique index forbids the same
|
||||
// document twice in one journey, and ordering doesn't depend on it.
|
||||
JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build();
|
||||
JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).note("erstes").build();
|
||||
JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).note("zweites").build();
|
||||
managed.getItems().addAll(List.of(third, first, second));
|
||||
geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
List<Integer> positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList();
|
||||
|
||||
assertThat(positions).containsExactly(1000, 2000, 3000);
|
||||
}
|
||||
|
||||
// ─── Cascade ALL + orphanRemoval ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_geschichte_cascade_deletes_all_journey_items() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build());
|
||||
managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build());
|
||||
geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
UUID geschichteId = journey.getId();
|
||||
geschichteRepository.deleteById(geschichteId);
|
||||
em.flush();
|
||||
|
||||
assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void removing_item_from_items_list_triggers_orphan_removal() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
reloaded.getItems().removeIf(i -> i.getId().equals(itemId));
|
||||
geschichteRepository.save(reloaded);
|
||||
em.flush();
|
||||
|
||||
assertThat(journeyItemRepository.findById(itemId)).isEmpty();
|
||||
}
|
||||
|
||||
// ─── GeschichteType round-trip ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void type_persists_as_JOURNEY_and_roundtrips() {
|
||||
Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void type_defaults_to_STORY_for_new_geschichten() {
|
||||
Geschichte story = geschichteRepository.save(Geschichte.builder()
|
||||
.title("Erinnerung")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow();
|
||||
assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY);
|
||||
}
|
||||
|
||||
// ─── Note-only item (document_id IS NULL) ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void note_only_item_persists_without_document() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem note = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).note("Eine kurze Einleitung.").build();
|
||||
managed.getItems().add(note);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID noteId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isNull();
|
||||
assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung.");
|
||||
}
|
||||
|
||||
// ─── Document-backed item exposes documentId ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void document_backed_item_exposes_document_uuid_via_getDocumentId() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).document(doc).build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow();
|
||||
assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
// ─── ON DELETE SET NULL ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void deleting_document_sets_item_document_to_null_not_delete_item() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).document(doc).note("still here").build();
|
||||
managed.getItems().add(item);
|
||||
Geschichte saved = geschichteRepository.save(managed);
|
||||
em.flush();
|
||||
UUID itemId = saved.getItems().get(0).getId(); // extract before clear
|
||||
em.clear();
|
||||
|
||||
// Route through service so the DocumentDeletingEvent fires and the listener
|
||||
// removes note-less items before ON DELETE SET NULL acts on note-carrying rows.
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow();
|
||||
assertThat(surviving.getDocumentId()).isNull();
|
||||
assertThat(surviving.getNote()).isEqualTo("still here");
|
||||
}
|
||||
|
||||
// ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ─────────
|
||||
|
||||
@Test
|
||||
void saving_item_with_neither_document_nor_note_violates_check_constraint() {
|
||||
Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow();
|
||||
JourneyItem empty = JourneyItem.builder()
|
||||
.geschichte(managed).position(1000).build();
|
||||
|
||||
assertThatThrownBy(() -> {
|
||||
journeyItemRepository.save(empty);
|
||||
journeyItemRepository.flush();
|
||||
}).isInstanceOf(Exception.class);
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.append — end-to-end persistence ──────────────────
|
||||
|
||||
@Test
|
||||
void append_persists_item_at_position_10() {
|
||||
// Arrange: authenticate as a user with BLOG_WRITE
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("First stop");
|
||||
|
||||
// Act
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item exists in DB at position 10
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
assertThat(view.note()).isEqualTo("First stop");
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(1);
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(0).getNote()).isEqualTo("First stop");
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_document_persists_and_rejects_duplicate() {
|
||||
// Covers the document branch of append, including the duplicate guard —
|
||||
// the derived exists query must resolve document.id, which the transient
|
||||
// getDocumentId() getter on JourneyItem shadows for Spring Data.
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
|
||||
JourneyItemView view = journeyItemService.append(journey.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(view.document()).isNotNull();
|
||||
assertThat(view.document().id()).isEqualTo(doc.getId());
|
||||
|
||||
JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO();
|
||||
duplicate.setDocumentId(doc.getId());
|
||||
assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate))
|
||||
.hasFieldOrPropertyWithValue("code",
|
||||
org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED);
|
||||
}
|
||||
|
||||
// ─── STORY-type Geschichten hold journey items (#795) ────────────────────
|
||||
|
||||
@Test
|
||||
void story_type_can_hold_journey_items_through_service() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
Geschichte story = savedStory();
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).id()).isEqualTo(appended.id());
|
||||
assertThat(items.get(0).document().id()).isEqualTo(doc.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void v72_migrated_story_items_keep_position_order_and_are_removable() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
Geschichte story = savedStory();
|
||||
Document docB = documentRepository.save(Document.builder()
|
||||
.title("Zweiter Brief").originalFilename("b.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
Document docC = documentRepository.save(Document.builder()
|
||||
.title("Dritter Brief").originalFilename("c.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
|
||||
// V72 inserted journey_items rows directly with position gaps — mirror that
|
||||
// by writing through the repository instead of the service.
|
||||
JourneyItem first = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(story).position(10).document(doc).build());
|
||||
JourneyItem second = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(story).position(20).document(docB).build());
|
||||
JourneyItem third = journeyItemRepository.save(
|
||||
JourneyItem.builder().geschichte(story).position(30).document(docC).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemService.getItems(story.getId()))
|
||||
.extracting(JourneyItemView::position)
|
||||
.containsExactly(10, 20, 30);
|
||||
|
||||
journeyItemService.delete(story.getId(), second.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemService.getItems(story.getId()))
|
||||
.extracting(JourneyItemView::id)
|
||||
.containsExactly(first.getId(), third.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void story_item_with_deleted_document_survives_and_remains_deletable() {
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
Geschichte story = savedStory();
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(doc.getId());
|
||||
// The note keeps chk_journey_item_not_empty satisfied once ON DELETE
|
||||
// SET NULL clears document_id — a note-less item would block the
|
||||
// document delete at the DB instead.
|
||||
dto.setNote("Begleittext");
|
||||
JourneyItemView appended = journeyItemService.append(story.getId(), dto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Route through service so the DocumentDeletingEvent fires (V72 cascade fix).
|
||||
documentService.deleteDocument(doc.getId(), writer.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
List<JourneyItemView> items = journeyItemService.getItems(story.getId());
|
||||
assertThat(items).hasSize(1);
|
||||
assertThat(items.get(0).document()).isNull();
|
||||
|
||||
journeyItemService.delete(story.getId(), appended.id());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(journeyItemService.getItems(story.getId())).isEmpty();
|
||||
}
|
||||
|
||||
private Geschichte savedStory() {
|
||||
return geschichteRepository.save(Geschichte.builder()
|
||||
.title("Eine Geschichte")
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.type(GeschichteType.STORY)
|
||||
.build());
|
||||
}
|
||||
|
||||
// ─── JourneyItemService.reorder — atomicity check ────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_swaps_positions_atomically() {
|
||||
// Arrange: append two items (pos 10, pos 20)
|
||||
authenticateAs(writer, Permission.BLOG_WRITE);
|
||||
|
||||
JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO();
|
||||
dto1.setNote("Item one");
|
||||
JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1);
|
||||
|
||||
JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO();
|
||||
dto2.setNote("Item two");
|
||||
JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2);
|
||||
|
||||
assertThat(item1View.position()).isEqualTo(10);
|
||||
assertThat(item2View.position()).isEqualTo(20);
|
||||
|
||||
// Act: reorder with [item2, item1]
|
||||
JourneyReorderDTO reorderDto = new JourneyReorderDTO();
|
||||
reorderDto.setItemIds(List.of(item2View.id(), item1View.id()));
|
||||
List<JourneyItemView> reordered = journeyItemService.reorder(journey.getId(), reorderDto);
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// Assert: item2 is now at position 10, item1 is at position 20
|
||||
List<JourneyItem> persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId());
|
||||
assertThat(persisted).hasSize(2);
|
||||
assertThat(persisted.get(0).getId()).isEqualTo(item2View.id());
|
||||
assertThat(persisted.get(0).getPosition()).isEqualTo(10);
|
||||
assertThat(persisted.get(1).getId()).isEqualTo(item1View.id());
|
||||
assertThat(persisted.get(1).getPosition()).isEqualTo(20);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
package org.raddatz.familienarchiv.geschichte.journeyitem;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.audit.AuditKind;
|
||||
import org.raddatz.familienarchiv.audit.AuditService;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.geschichte.Geschichte;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteQueryService;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
|
||||
import org.raddatz.familienarchiv.geschichte.GeschichteType;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import org.postgresql.util.PSQLException;
|
||||
import org.postgresql.util.PSQLState;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class JourneyItemServiceTest {
|
||||
|
||||
@Mock JourneyItemRepository journeyItemRepository;
|
||||
@Mock GeschichteQueryService geschichteQueryService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock AuditService auditService;
|
||||
@Mock UserService userService;
|
||||
|
||||
@InjectMocks JourneyItemService journeyItemService;
|
||||
|
||||
UUID geschichteId = UUID.randomUUID();
|
||||
UUID itemId = UUID.randomUUID();
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID actorId = UUID.randomUUID();
|
||||
|
||||
@BeforeEach
|
||||
void setupAuth() {
|
||||
AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build();
|
||||
lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor);
|
||||
lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true);
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
new UsernamePasswordAuthenticationToken("test@test.de", null,
|
||||
List.of(new SimpleGrantedAuthority("BLOG_WRITE"))));
|
||||
}
|
||||
|
||||
// ─── toSummary — name composition ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void toSummary_uses_linked_person_firstName_lastName() {
|
||||
Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build();
|
||||
Document doc = makeDoc(docId, sender, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Franz Raddatz");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_falls_back_to_senderText_when_no_person() {
|
||||
Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isEqualTo("Familie Müller");
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_returns_null_senderName_when_neither_person_nor_text() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.senderName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_receiverCount_0_and_null_name_when_no_receiver() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(0);
|
||||
assertThat(summary.receiverName()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() {
|
||||
Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build();
|
||||
Person anna = Person.builder().firstName("Anna").lastName("Amann").build();
|
||||
Document doc = makeDoc(docId, null, List.of(emma, anna), null, null);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.receiverCount()).isEqualTo(2);
|
||||
assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_SEASON_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.SEASON);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toSummary_datePrecision_APPROX_roundtrips() {
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
doc.setMetaDatePrecision(DatePrecision.APPROX);
|
||||
|
||||
var summary = journeyItemService.toSummary(doc);
|
||||
|
||||
assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX);
|
||||
}
|
||||
|
||||
// ─── append ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void append_to_empty_journey_starts_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_after_reorder_continues_from_max_position() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40));
|
||||
JourneyItem saved = savedItem(itemId, journey, 50, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_neither_documentId_nor_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("documentId or note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns400_when_note_trims_to_empty_and_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote(" \n ");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
|
||||
// 2000 is the spec'd limit (frontend maxlength + i18n message agree) — see #793.
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("x".repeat(2001));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_accepts_note_of_exactly_2000_chars() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "x".repeat(2000));
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("x".repeat(2000));
|
||||
|
||||
assertThat(journeyItemService.append(geschichteId, dto).note()).hasSize(2000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns404_when_documentId_does_not_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(documentService.findSummaryByIdInternal(docId))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found"));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_100_items_exist() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_returns409_when_document_already_in_journey() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_to_STORY_type_creates_journey_item() {
|
||||
Geschichte story = story(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(false);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
when(documentService.findSummaryByIdInternal(docId)).thenReturn(doc);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(savedItemWithDoc(itemId, story, 10, doc, null));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
JourneyItemView view = journeyItemService.append(geschichteId, dto);
|
||||
|
||||
assertThat(view.position()).isEqualTo(10);
|
||||
assertThat(view.document().id()).isEqualTo(docId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_to_STORY_type_respects_capacity_cap() {
|
||||
Geschichte story = story(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_to_STORY_type_rejects_duplicate_document() {
|
||||
Geschichte story = story(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(docId);
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cap_is_COUNT_based_not_MAX_position_based() {
|
||||
// 99 rows with MAX(position)=2000 should still accept the 100th append
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000));
|
||||
JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
|
||||
assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
|
||||
// Two concurrent appends can both pass the exists() pre-check; the partial
|
||||
// unique index then rejects the second INSERT at flush. The service must
|
||||
// translate that into the same friendly 409 as the pre-check.
|
||||
// Uses PSQLException with SQLState 23505 — the real payload Postgres delivers.
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
|
||||
PSQLState.UNIQUE_VIOLATION);
|
||||
when(journeyItemRepository.saveAndFlush(any()))
|
||||
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
||||
"could not execute statement", psqlEx));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
|
||||
// B1: the dedup check must use PSQLException.getSQLState() == "23505", not
|
||||
// constraint-name string matching — constraint renames must not regress this.
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||
|
||||
// Simulate a real Postgres unique-violation: PSQLException with SQLState 23505
|
||||
// wrapped by Spring's DataIntegrityViolationException.
|
||||
PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint",
|
||||
PSQLState.UNIQUE_VIOLATION);
|
||||
org.springframework.dao.DataIntegrityViolationException dive =
|
||||
new org.springframework.dao.DataIntegrityViolationException("could not execute statement", psqlEx);
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenThrow(dive);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() throws Exception {
|
||||
// An FK violation (document deleted between load and flush) must NOT be
|
||||
// translated into "already added" — only the dedup unique index (23505) earns that 409.
|
||||
// FK violations arrive as PSQLException with SQLState 23503 (foreign_key_violation).
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
||||
PSQLException psqlEx = new PSQLException("foreign key violation", PSQLState.FOREIGN_KEY_VIOLATION);
|
||||
when(journeyItemRepository.saveAndFlush(any()))
|
||||
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
||||
"could not execute statement", psqlEx));
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setDocumentId(UUID.randomUUID());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void append_audits_JOURNEY_ITEM_ADDED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L);
|
||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty());
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.saveAndFlush(any())).thenReturn(saved);
|
||||
|
||||
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||
dto.setNote("Note");
|
||||
journeyItemService.append(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── updateNote ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updateNote_absent_leaves_note_unchanged() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Original note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
// note is null by default — absent from JSON, no-op
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("Original note");
|
||||
verify(journeyItemRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_clears_note_when_document_is_present() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_string_sets_note() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
item.setNote(null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isEqualTo("New note");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_null_returns400_when_item_has_no_document() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_whitespace_only_including_newlines_stored_as_null() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
Document doc = makeDoc(docId, null, List.of(), null, null);
|
||||
JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null);
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("\n \n"));
|
||||
|
||||
JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
assertThat(view.note()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_rejects_note_longer_than_2000_chars_with_JOURNEY_NOTE_TOO_LONG() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Old");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("x".repeat(2001)));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_NOTE_TOO_LONG));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateNote_auditsNoteUpdate() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, null);
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
JourneyItem saved = savedItem(itemId, journey, 10, null, "New note");
|
||||
when(journeyItemRepository.save(item)).thenReturn(saved);
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("New note"));
|
||||
journeyItemService.updateNote(geschichteId, itemId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void patch_returns404_when_item_belongs_to_different_journey() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO();
|
||||
dto.setNote(Optional.of("text"));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
// ─── delete ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void delete_returns404_when_item_already_deleted() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_no_audit_when_item_not_found() {
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId))
|
||||
.isInstanceOf(DomainException.class);
|
||||
|
||||
verify(auditService, never()).logAfterCommit(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
JourneyItem item = savedItem(itemId, journey, 10, null, "Note");
|
||||
when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item));
|
||||
|
||||
journeyItemService.delete(geschichteId, itemId);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── reorder ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void reorder_unknownGeschichteId_throws404() {
|
||||
UUID unknownId = UUID.randomUUID();
|
||||
// geschichteQueryService is not lenient-stubbed for unknownId → returns false
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemIds_contain_duplicates() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id1)); // duplicate
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_itemId_belongs_to_different_journey() {
|
||||
UUID foreignId = UUID.randomUUID();
|
||||
UUID localId = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(foreignId));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.VALIDATION_ERROR));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_ids_have_extra_items() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2));
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns200_when_empty_on_empty_journey() {
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of());
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
List<JourneyItemView> result = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns400_when_empty_on_nonempty_journey() {
|
||||
UUID id1 = UUID.randomUUID();
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of());
|
||||
|
||||
assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_returns_items_in_new_order_starting_at_10() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
UUID id2 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 20, null, "A");
|
||||
JourneyItem item2 = savedItem(id2, journey, 10, null, "B");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1, id2));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item2, item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1, id2)); // want id1 first
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(2);
|
||||
assertThat(views.get(0).id()).isEqualTo(id1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
assertThat(views.get(1).id()).isEqualTo(id2);
|
||||
assertThat(views.get(1).position()).isEqualTo(20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_identical_order_returns200() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(1);
|
||||
assertThat(views.get(0).position()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_of_grandfathered_over_cap_journey_succeeds() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
// 130-item journey — reorder with all 130 IDs must succeed despite > 100 cap
|
||||
List<UUID> ids = new java.util.ArrayList<>();
|
||||
List<JourneyItem> items = new java.util.ArrayList<>();
|
||||
for (int i = 1; i <= 130; i++) {
|
||||
UUID id = UUID.randomUUID();
|
||||
ids.add(id);
|
||||
items.add(savedItem(id, journey, i * 10, null, "item " + i));
|
||||
}
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(new HashSet<>(ids));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(items);
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(ids);
|
||||
|
||||
List<JourneyItemView> views = journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
assertThat(views).hasSize(130);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reorder_audits_JOURNEY_ITEMS_REORDERED() {
|
||||
Geschichte journey = journey(geschichteId);
|
||||
UUID id1 = UUID.randomUUID();
|
||||
JourneyItem item1 = savedItem(id1, journey, 10, null, "A");
|
||||
when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1));
|
||||
when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1));
|
||||
when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
JourneyReorderDTO dto = new JourneyReorderDTO();
|
||||
dto.setItemIds(List.of(id1));
|
||||
journeyItemService.reorder(geschichteId, dto);
|
||||
|
||||
verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any());
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Geschichte journey(UUID id) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title("Test Journey")
|
||||
.type(GeschichteType.JOURNEY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Geschichte story(UUID id) {
|
||||
return Geschichte.builder()
|
||||
.id(id)
|
||||
.title("Test Story")
|
||||
.type(GeschichteType.STORY)
|
||||
.status(GeschichteStatus.DRAFT)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
return JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(null) // no document entity to avoid LAZY issues in unit tests
|
||||
.note(note)
|
||||
.build();
|
||||
}
|
||||
|
||||
private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) {
|
||||
JourneyItem item = JourneyItem.builder()
|
||||
.id(id)
|
||||
.geschichte(g)
|
||||
.position(position)
|
||||
.document(doc)
|
||||
.note(note)
|
||||
.build();
|
||||
return item;
|
||||
}
|
||||
|
||||
private Document makeDoc(UUID id, Person sender, List<Person> receivers, String senderText, String receiverText) {
|
||||
Document doc = Document.builder()
|
||||
.id(id)
|
||||
.title("Test Doc")
|
||||
.originalFilename("test.pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.senderText(senderText)
|
||||
.receiverText(receiverText)
|
||||
.sender(sender)
|
||||
.build();
|
||||
doc.setReceivers(new HashSet<>(receivers));
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package org.raddatz.familienarchiv.person;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Verifies V76: persons.birth_year/death_year (integer) become
|
||||
* birth_date/death_date (date) + *_date_precision columns, with backfill to
|
||||
* YYYY-01-01 at YEAR precision, named CHECK constraints, and a data-quality
|
||||
* pre-check that aborts the migration on corrupt year data.
|
||||
*
|
||||
* <p>Runs Flyway programmatically (no Spring context): each test gets its own
|
||||
* database so the staged migrate-to-V75 → seed → migrate-to-latest flow and
|
||||
* the abort cases cannot interfere with each other.
|
||||
*/
|
||||
class PersonBirthDeathMigrationTest {
|
||||
|
||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
private static final AtomicInteger DB_COUNTER = new AtomicInteger();
|
||||
|
||||
private String dbUrl;
|
||||
|
||||
@BeforeAll
|
||||
static void startContainer() {
|
||||
POSTGRES.start();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopContainer() {
|
||||
POSTGRES.stop();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void createFreshDatabase() throws SQLException {
|
||||
String dbName = "mig_v76_" + DB_COUNTER.incrementAndGet();
|
||||
try (Connection conn = DriverManager.getConnection(
|
||||
baseUrl("postgres"), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("CREATE DATABASE " + dbName);
|
||||
}
|
||||
dbUrl = baseUrl(dbName);
|
||||
}
|
||||
|
||||
@Test
|
||||
void precheck_abortsWhenBirthYearAfterDeathYear() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("Corrupt", 1950, 1940);
|
||||
|
||||
assertThatThrownBy(this::migrateToLatest)
|
||||
.hasMessageContaining("V76 aborted")
|
||||
.hasMessageContaining("birth_year > death_year");
|
||||
}
|
||||
|
||||
@Test
|
||||
void precheck_abortsWhenYearZeroPresent() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("Zero", 0, null);
|
||||
|
||||
assertThatThrownBy(this::migrateToLatest)
|
||||
.hasMessageContaining("V76 aborted")
|
||||
.hasMessageContaining("birth_year=0 or death_year=0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_birthOnly_becomesYearPrecisionDate_deathStaysUnknown() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("BirthOnly", 1901, null);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
LifeDates row = lifeDates("BirthOnly");
|
||||
assertThat(row.birthDate()).hasToString("1901-01-01");
|
||||
assertThat(row.birthPrecision()).isEqualTo("YEAR");
|
||||
assertThat(row.deathDate()).isNull();
|
||||
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_deathOnly_becomesYearPrecisionDate_birthStaysUnknown() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("DeathOnly", null, 1944);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
LifeDates row = lifeDates("DeathOnly");
|
||||
assertThat(row.birthDate()).isNull();
|
||||
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
|
||||
assertThat(row.deathDate()).hasToString("1944-01-01");
|
||||
assertThat(row.deathPrecision()).isEqualTo("YEAR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_bothNull_leavesDatesNullAndPrecisionsUnknown() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("NoDates", null, null);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
LifeDates row = lifeDates("NoDates");
|
||||
assertThat(row.birthDate()).isNull();
|
||||
assertThat(row.birthPrecision()).isEqualTo("UNKNOWN");
|
||||
assertThat(row.deathDate()).isNull();
|
||||
assertThat(row.deathPrecision()).isEqualTo("UNKNOWN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void backfill_neverProducesBirthDateAfterDeathDate() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("SameYear", 1901, 1901);
|
||||
seedPerson("Ordered", 1899, 1972);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
assertThat(countWhere("birth_date IS NOT NULL AND death_date IS NOT NULL AND birth_date > death_date"))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void yearColumnsDropped_andNamedCheckConstraintsExist() throws SQLException {
|
||||
migrateTo("75");
|
||||
seedPerson("Schema", 1901, 1944);
|
||||
|
||||
migrateToLatest();
|
||||
|
||||
assertThat(columnExists("birth_year")).isFalse();
|
||||
assertThat(columnExists("death_year")).isFalse();
|
||||
assertThat(columnExists("birth_date")).isTrue();
|
||||
assertThat(columnExists("death_date")).isTrue();
|
||||
for (String constraint : new String[]{
|
||||
"chk_person_birth_before_death",
|
||||
"chk_person_birth_date_precision_coherence",
|
||||
"chk_person_birth_date_precision_values",
|
||||
"chk_person_death_date_precision_coherence",
|
||||
"chk_person_death_date_precision_values"}) {
|
||||
assertThat(constraintExists(constraint)).as(constraint).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static String baseUrl(String dbName) {
|
||||
return "jdbc:postgresql://" + POSTGRES.getHost() + ":" + POSTGRES.getMappedPort(5432) + "/" + dbName;
|
||||
}
|
||||
|
||||
private void migrateTo(String targetVersion) {
|
||||
flywayBuilder().target(targetVersion).load().migrate();
|
||||
}
|
||||
|
||||
private void migrateToLatest() {
|
||||
flywayBuilder().load().migrate();
|
||||
}
|
||||
|
||||
private org.flywaydb.core.api.configuration.FluentConfiguration flywayBuilder() {
|
||||
return Flyway.configure()
|
||||
.dataSource(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword())
|
||||
.locations("classpath:db/migration")
|
||||
.placeholders(Map.of("grafanaDbPassword", "test-only"));
|
||||
}
|
||||
|
||||
private void seedPerson(String lastName, Integer birthYear, Integer deathYear) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"INSERT INTO persons (id, last_name, person_type, family_member, provisional, birth_year, death_year) "
|
||||
+ "VALUES (gen_random_uuid(), ?, 'PERSON', false, false, ?, ?)")) {
|
||||
stmt.setString(1, lastName);
|
||||
stmt.setObject(2, birthYear);
|
||||
stmt.setObject(3, deathYear);
|
||||
stmt.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private record LifeDates(Object birthDate, String birthPrecision, Object deathDate, String deathPrecision) {}
|
||||
|
||||
private LifeDates lifeDates(String lastName) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT birth_date, birth_date_precision, death_date, death_date_precision "
|
||||
+ "FROM persons WHERE last_name = ?")) {
|
||||
stmt.setString(1, lastName);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
assertThat(rs.next()).as("person %s exists", lastName).isTrue();
|
||||
return new LifeDates(
|
||||
rs.getObject("birth_date"),
|
||||
rs.getString("birth_date_precision"),
|
||||
rs.getObject("death_date"),
|
||||
rs.getString("death_date_precision"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long countWhere(String condition) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM persons WHERE " + condition)) {
|
||||
rs.next();
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean columnExists(String columnName) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
+ "WHERE table_schema = 'public' AND table_name = 'persons' AND column_name = ?")) {
|
||||
stmt.setString(1, columnName);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getInt(1) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean constraintExists(String constraintName) throws SQLException {
|
||||
try (Connection conn = connect();
|
||||
PreparedStatement stmt = conn.prepareStatement(
|
||||
"SELECT COUNT(*) FROM pg_constraint WHERE conname = ?")) {
|
||||
stmt.setString(1, constraintName);
|
||||
try (ResultSet rs = stmt.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getInt(1) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Connection connect() throws SQLException {
|
||||
return DriverManager.getConnection(dbUrl, POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAlias;
|
||||
@@ -22,6 +23,7 @@ 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.time.LocalDate;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -215,6 +217,14 @@ class PersonControllerTest {
|
||||
public String getAlias() { return null; }
|
||||
public Integer getBirthYear() { return null; }
|
||||
public Integer getDeathYear() { return null; }
|
||||
public java.time.LocalDate getBirthDate() { return null; }
|
||||
public org.raddatz.familienarchiv.document.DatePrecision getBirthDatePrecision() {
|
||||
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
|
||||
}
|
||||
public java.time.LocalDate getDeathDate() { return null; }
|
||||
public org.raddatz.familienarchiv.document.DatePrecision getDeathDatePrecision() {
|
||||
return org.raddatz.familienarchiv.document.DatePrecision.UNKNOWN;
|
||||
}
|
||||
public String getNotes() { return null; }
|
||||
public boolean isFamilyMember() { return false; }
|
||||
public boolean isProvisional() { return false; }
|
||||
@@ -572,18 +582,53 @@ class PersonControllerTest {
|
||||
void createPerson_returns200_withAllSixFields() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
|
||||
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
||||
.alias("Oma Maria")
|
||||
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
|
||||
.deathDate(LocalDate.of(1975, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
|
||||
.notes("Some notes").build();
|
||||
when(personService.createPerson(any(org.raddatz.familienarchiv.person.PersonUpdateDTO.class))).thenReturn(saved);
|
||||
|
||||
mockMvc.perform(post("/api/persons").with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||
"\"alias\":\"Oma Maria\"," +
|
||||
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"DAY\"," +
|
||||
"\"deathDate\":\"1975-01-01\",\"deathDatePrecision\":\"YEAR\"," +
|
||||
"\"notes\":\"Some notes\",\"personType\":\"PERSON\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||
.andExpect(jsonPath("$.birthYear").value(1901));
|
||||
.andExpect(jsonPath("$.birthDate").value("1901-03-14"))
|
||||
.andExpect(jsonPath("$.birthDatePrecision").value("DAY"));
|
||||
}
|
||||
|
||||
// ─── #773: malformed date payloads return structured 400s, not Jackson traces ──
|
||||
// Jackson rejects unknown enum values by default. Verified 2026-06-12: the only
|
||||
// DeserializationFeature hit in src/main is RestClientOcrClient's private ObjectMapper
|
||||
// (OCR HTTP client) — the Spring MVC mapper has no READ_UNKNOWN_ENUM_VALUES_* override.
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400WithStructuredErrorCode_whenPrecisionEnumInvalid() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
|
||||
"\"birthDate\":\"1901-03-14\",\"birthDatePrecision\":\"INVALID_VALUE\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void updatePerson_returns400WithStructuredErrorCode_whenBirthDateNotADate() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
mockMvc.perform(put("/api/persons/{id}", id).with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"personType\":\"PERSON\"," +
|
||||
"\"birthDate\":\"not-a-date\",\"birthDatePrecision\":\"DAY\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
|
||||
}
|
||||
|
||||
// ─── Phase 1.2: @Size constraints ─────────────────────────────────────────
|
||||
|
||||
@@ -5,7 +5,9 @@ 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.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -97,24 +99,120 @@ class PersonImportUpsertTest {
|
||||
assertThat(result.getNotes()).isEqualTo("Nichte von Herbert");
|
||||
}
|
||||
|
||||
// ─── life dates (ADR-025 extension via preferHumanDate, #773) ─────────────
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_fillsBlankYears_butPreservesHumanEditedYears_onReimport() {
|
||||
// Existing has a human-set birthYear and a blank deathYear.
|
||||
Person existing = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram")
|
||||
.lastName("Cram").birthYear(1890).deathYear(null).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
|
||||
void upsertBySourceRef_preservesDayPrecisionDate_onReimportWithDifferentYear() {
|
||||
// A human entered the exact birthday in-app; the spreadsheet only knows a year.
|
||||
Person handDated = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.birthDate(LocalDate.of(1890, 3, 14)).birthDatePrecision(DatePrecision.DAY).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1888).deathYear(1965)
|
||||
.birthYear(1888)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1890); // human value kept
|
||||
assertThat(result.getDeathYear()).isEqualTo(1965); // blank filled from canonical
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 3, 14));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_preservesMonthPrecisionDate_onReimport() {
|
||||
Person handDated = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.deathDate(LocalDate.of(1944, 11, 1)).deathDatePrecision(DatePrecision.MONTH).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.deathYear(1945)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 1));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.MONTH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_refreshesYearPrecisionDate_whenSpreadsheetYearChanges() {
|
||||
// YEAR precision means "the importer's year" — a corrected spreadsheet year wins.
|
||||
Person yearOnly = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.birthDate(LocalDate.of(1890, 1, 1)).birthDatePrecision(DatePrecision.YEAR).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(yearOnly));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1888)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1888, 1, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_fillsEmptyDateAtYearPrecision_onReimport() {
|
||||
Person noDates = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.deathYear(1965)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_keepsDatesEmpty_whenSpreadsheetHasNoYear() {
|
||||
Person noDates = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram").build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(noDates));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isNull();
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_translatesYearToDate_onFirstImport() {
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1890).deathYear(1965)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 1, 1));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -199,4 +297,70 @@ class PersonImportUpsertTest {
|
||||
|
||||
assertThat(result.getGeneration()).isEqualTo(3);
|
||||
}
|
||||
|
||||
// ─── conflicting canonical life dates degrade instead of hitting the DB CHECK ──
|
||||
// (chk_person_birth_before_death would abort the whole batch — REQ-IMP-001)
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_dropsBothDates_whenCanonicalBirthAfterDeath_newPerson() {
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.empty());
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1950).deathYear(1949)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isNull();
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_keepsHandEnteredBirth_andDropsConflictingCanonicalDeath() {
|
||||
// A human entered an exact birthday; the spreadsheet's death year lies before it.
|
||||
// The hand-entered side must survive, the conflicting canonical refresh is dropped.
|
||||
Person handDated = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.birthDate(LocalDate.of(1950, 6, 1)).birthDatePrecision(DatePrecision.DAY).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(handDated));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.deathYear(1949)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1950, 6, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void upsertBySourceRef_keepsExistingYearDates_whenCanonicalRefreshConflicts() {
|
||||
Person existing = Person.builder()
|
||||
.id(UUID.randomUUID()).sourceRef("clara-cram").lastName("Cram")
|
||||
.birthDate(LocalDate.of(1900, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
|
||||
.deathDate(LocalDate.of(1980, 1, 1)).deathDatePrecision(DatePrecision.YEAR).build();
|
||||
when(personRepository.findBySourceRef("clara-cram")).thenReturn(Optional.of(existing));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpsertCommand cmd = PersonUpsertCommand.builder()
|
||||
.sourceRef("clara-cram").lastName("Cram")
|
||||
.birthYear(1990).deathYear(1985)
|
||||
.personType(PersonType.PERSON).provisional(false).build();
|
||||
|
||||
Person result = personService.upsertBySourceRef(cmd);
|
||||
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1980, 1, 1));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -910,4 +913,146 @@ class PersonRepositoryTest {
|
||||
.setParameter(1, blockId).getSingleResult();
|
||||
assertThat(text).isEqualTo("Brief an @Auguste Raddatz und @Clara Cram");
|
||||
}
|
||||
|
||||
// ─── #773: PersonSummaryDTO year projection from birth_date/death_date ──────
|
||||
|
||||
@Test
|
||||
void findAllWithDocumentCount_derivesYearsFromDates_nullSafe() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Maria").lastName("Datiert")
|
||||
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
|
||||
.build());
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Nora").lastName("Undatiert")
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
|
||||
|
||||
PersonSummaryDTO dated = all.stream()
|
||||
.filter(p -> "Datiert".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(dated.getBirthYear()).isEqualTo(1901);
|
||||
assertThat(dated.getDeathYear()).isNull();
|
||||
PersonSummaryDTO undated = all.stream()
|
||||
.filter(p -> "Undatiert".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(undated.getBirthYear()).isNull();
|
||||
assertThat(undated.getDeathYear()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithDocumentCount_groupByPath_derivesYearsFromDates() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Herbert").lastName("Gruppiert")
|
||||
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
|
||||
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Gruppiert");
|
||||
|
||||
assertThat(found).hasSize(1);
|
||||
assertThat(found.get(0).getBirthYear()).isEqualTo(1899);
|
||||
assertThat(found.get(0).getDeathYear()).isEqualTo(1972);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByFilter_derivesYearsFromDates() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Filtriert").lastName("Person")
|
||||
.birthDate(LocalDate.of(1920, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> found = personRepository.findByFilter(
|
||||
null, null, null, null, false, "Filtriert", 10, 0);
|
||||
|
||||
assertThat(found).hasSize(1);
|
||||
assertThat(found.get(0).getBirthYear()).isEqualTo(1920);
|
||||
assertThat(found.get(0).getDeathYear()).isNull();
|
||||
}
|
||||
|
||||
// ─── #773 follow-up: full date + precision exposed on the summary projection ──
|
||||
// (the mention dropdown renders precise life dates from the list endpoint)
|
||||
|
||||
@Test
|
||||
void findAllWithDocumentCount_exposesDateAndPrecisionFields() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Maria").lastName("Praezise")
|
||||
.birthDate(LocalDate.of(1901, 3, 14)).birthDatePrecision(DatePrecision.DAY)
|
||||
.deathDate(LocalDate.of(1972, 1, 1)).deathDatePrecision(DatePrecision.YEAR)
|
||||
.build());
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Nora").lastName("Datenlos")
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> all = personRepository.findAllWithDocumentCount();
|
||||
|
||||
PersonSummaryDTO dated = all.stream()
|
||||
.filter(p -> "Praezise".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(dated.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
|
||||
assertThat(dated.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(dated.getDeathDate()).isEqualTo(LocalDate.of(1972, 1, 1));
|
||||
assertThat(dated.getDeathDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
PersonSummaryDTO undated = all.stream()
|
||||
.filter(p -> "Datenlos".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(undated.getBirthDate()).isNull();
|
||||
assertThat(undated.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(undated.getDeathDate()).isNull();
|
||||
assertThat(undated.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchWithDocumentCount_exposesDateAndPrecisionFields() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Herbert").lastName("Suchbar")
|
||||
.birthDate(LocalDate.of(1899, 1, 1)).birthDatePrecision(DatePrecision.YEAR)
|
||||
.deathDate(LocalDate.of(1972, 6, 12)).deathDatePrecision(DatePrecision.DAY)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> found = personRepository.searchWithDocumentCount("Suchbar");
|
||||
|
||||
assertThat(found).hasSize(1);
|
||||
assertThat(found.get(0).getBirthDate()).isEqualTo(LocalDate.of(1899, 1, 1));
|
||||
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1972, 6, 12));
|
||||
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findTopByDocumentCount_exposesDateAndPrecisionFields() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Top").lastName("Dokumentiert")
|
||||
.birthDate(LocalDate.of(1910, 5, 1)).birthDatePrecision(DatePrecision.MONTH)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> top = personRepository.findTopByDocumentCount(10);
|
||||
|
||||
PersonSummaryDTO found = top.stream()
|
||||
.filter(p -> "Dokumentiert".equals(p.getLastName())).findFirst().orElseThrow();
|
||||
assertThat(found.getBirthDate()).isEqualTo(LocalDate.of(1910, 5, 1));
|
||||
assertThat(found.getBirthDatePrecision()).isEqualTo(DatePrecision.MONTH);
|
||||
assertThat(found.getDeathDate()).isNull();
|
||||
assertThat(found.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByFilter_exposesDateAndPrecisionFields() {
|
||||
personRepository.save(Person.builder()
|
||||
.firstName("Gefiltert").lastName("Genau")
|
||||
.deathDate(LocalDate.of(1944, 11, 2)).deathDatePrecision(DatePrecision.DAY)
|
||||
.build());
|
||||
entityManager.flush();
|
||||
|
||||
List<PersonSummaryDTO> found = personRepository.findByFilter(
|
||||
null, null, null, null, false, "Gefiltert", 10, 0);
|
||||
|
||||
assertThat(found).hasSize(1);
|
||||
assertThat(found.get(0).getBirthDate()).isNull();
|
||||
assertThat(found.get(0).getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(found.get(0).getDeathDate()).isEqualTo(LocalDate.of(1944, 11, 2));
|
||||
assertThat(found.get(0).getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAliasDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonSummaryDTO;
|
||||
import org.raddatz.familienarchiv.person.PersonUpdateDTO;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAlias;
|
||||
import org.raddatz.familienarchiv.person.PersonNameAliasType;
|
||||
@@ -17,6 +19,7 @@ import org.raddatz.familienarchiv.person.PersonNameAliasRepository;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -241,27 +244,49 @@ class PersonServiceTest {
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria");
|
||||
dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes");
|
||||
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.DAY);
|
||||
dto.setDeathDate(LocalDate.of(1975, 11, 2)); dto.setDeathDatePrecision(DatePrecision.DAY);
|
||||
dto.setNotes("Some notes");
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getFirstName()).isEqualTo("Maria");
|
||||
assertThat(result.getLastName()).isEqualTo("Raddatz");
|
||||
assertThat(result.getAlias()).isEqualTo("Oma Maria");
|
||||
assertThat(result.getBirthYear()).isEqualTo(1901);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1975);
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1901, 3, 14));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1975, 11, 2));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(result.getNotes()).isEqualTo("Some notes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_yearValidationFires_whenBirthYearNegative() {
|
||||
void createPerson_dto_rejectsDateWithUnknownPrecision() {
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Test");
|
||||
dto.setBirthDate(LocalDate.of(1901, 3, 14)); dto.setBirthDatePrecision(DatePrecision.UNKNOWN);
|
||||
|
||||
assertThatThrownBy(() -> personService.createPerson(dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> {
|
||||
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPerson_dto_treatsNullPrecisionWithNullDateAsUnknown() {
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setPersonType(PersonType.PERSON);
|
||||
|
||||
Person result = personService.createPerson(dto);
|
||||
|
||||
assertThat(result.getBirthDate()).isNull();
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -600,114 +625,135 @@ class PersonServiceTest {
|
||||
assertThat(result.getNotes()).isNull();
|
||||
}
|
||||
|
||||
// ─── updatePerson (birth/death years) ────────────────────────────────────
|
||||
// ─── updatePerson (birth/death dates) ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_persistsBirthAndDeathYear() {
|
||||
void updatePerson_persistsBirthAndDeathDateWithPrecision() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(1965);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
|
||||
dto.setDeathDate(LocalDate.of(1965, 6, 12)); dto.setDeathDatePrecision(DatePrecision.DAY);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1890);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1965);
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
|
||||
assertThat(result.getBirthDatePrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1965, 6, 12));
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.DAY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() {
|
||||
void updatePerson_throwsBirthAfterDeath_whenBirthDateAfterDeathDate() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1970); dto.setDeathYear(1950);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1970, 5, 1)); dto.setBirthDatePrecision(DatePrecision.DAY);
|
||||
dto.setDeathDate(LocalDate.of(1950, 5, 1)); dto.setDeathDatePrecision(DatePrecision.DAY);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> {
|
||||
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
|
||||
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() {
|
||||
// Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw
|
||||
void updatePerson_throwsBirthAfterDeath_onMixedPrecisionLateBirthday() {
|
||||
// Known limitation (#773): DAY-precision birth late in the death's YEAR-precision year
|
||||
// compares against the year's backfilled Jan 1st and is rejected. The error message
|
||||
// carries the workaround hint via the BIRTH_AFTER_DEATH i18n key.
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1901, 11, 15)); dto.setBirthDatePrecision(DatePrecision.DAY);
|
||||
dto.setDeathDate(LocalDate.of(1901, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.BIRTH_AFTER_DEATH);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_doesNotThrow_whenBirthDateSetButDeathDateNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(null);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1890, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1890);
|
||||
assertThat(result.getDeathYear()).isNull();
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1890, 1, 1));
|
||||
assertThat(result.getDeathDate()).isNull();
|
||||
assertThat(result.getDeathDatePrecision()).isEqualTo(DatePrecision.UNKNOWN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_allowsSameYear() {
|
||||
void updatePerson_allowsEqualBirthAndDeathDate() {
|
||||
UUID id = UUID.randomUUID();
|
||||
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1900); dto.setDeathYear(1900);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1900, 1, 1)); dto.setBirthDatePrecision(DatePrecision.YEAR);
|
||||
dto.setDeathDate(LocalDate.of(1900, 1, 1)); dto.setDeathDatePrecision(DatePrecision.YEAR);
|
||||
Person result = personService.updatePerson(id, dto);
|
||||
|
||||
assertThat(result.getBirthYear()).isEqualTo(1900);
|
||||
assertThat(result.getDeathYear()).isEqualTo(1900);
|
||||
assertThat(result.getBirthDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||
assertThat(result.getDeathDate()).isEqualTo(LocalDate.of(1900, 1, 1));
|
||||
}
|
||||
|
||||
// ─── Phase 1.3: Year range bounds (> 0) ──────────────────────────────────
|
||||
// ─── Date/precision coherence (V76 CHECK constraint mirror) ─────────────
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenBirthYearIsZero() {
|
||||
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionUnknown() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setDeathDate(LocalDate.of(1944, 11, 2)); dto.setDeathDatePrecision(DatePrecision.UNKNOWN);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.satisfies(e -> {
|
||||
assertThat(((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||
assertThat(((DomainException) e).getStatus().value()).isEqualTo(400);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenBirthYearIsNegative() {
|
||||
void updatePerson_throwsInvalidDatePrecision_whenDatePresentButPrecisionNull() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDate(LocalDate.of(1901, 3, 14));
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenDeathYearIsZero() {
|
||||
void updatePerson_throwsInvalidDatePrecision_whenPrecisionSetWithoutDate() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0);
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt");
|
||||
dto.setBirthDatePrecision(DatePrecision.DAY);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatePerson_throwsBadRequest_whenDeathYearIsNegative() {
|
||||
UUID id = UUID.randomUUID();
|
||||
|
||||
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10);
|
||||
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(400);
|
||||
.isInstanceOf(DomainException.class)
|
||||
.extracting(e -> ((DomainException) e).getCode())
|
||||
.isEqualTo(ErrorCode.INVALID_DATE_PRECISION);
|
||||
}
|
||||
|
||||
// ─── findCorrespondents ──────────────────────────────────────────────────
|
||||
|
||||
@@ -122,7 +122,8 @@ class ArchitectureTest {
|
||||
.that().areAnnotatedWith(Entity.class)
|
||||
.should().resideInAnyPackage(
|
||||
"..document..", "..person..", "..tag..", "..user..",
|
||||
"..geschichte..", "..notification..", "..ocr..", "..audit.."
|
||||
"..geschichte..", "..notification..", "..ocr..", "..audit..",
|
||||
"..timeline.."
|
||||
);
|
||||
|
||||
// TODO Rule 5: Controllers expose endpoints under their domain prefix
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
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.DocumentService;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.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.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Proves V77's FK {@code ON DELETE CASCADE} on the join tables: deleting a linked Person or
|
||||
* Document drops the join row and leaves the {@link TimelineEvent} intact (a person/document
|
||||
* delete must never 500 — V71-class regression guard). Needs the full Spring context for
|
||||
* {@link PersonService}/{@link DocumentService}, mirroring {@code PersonServiceIntegrationTest}.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class TimelineEventCascadeIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
@Autowired TimelineEventRepository events;
|
||||
@Autowired PersonRepository personRepository;
|
||||
@Autowired PersonService personService;
|
||||
@Autowired DocumentRepository documentRepository;
|
||||
@Autowired DocumentService documentService;
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
@PersistenceContext EntityManager em;
|
||||
|
||||
private TimelineEvent.TimelineEventBuilder makeEvent() {
|
||||
return TimelineEvent.builder()
|
||||
.title("Hochzeit von Anna und Otto")
|
||||
.type(EventType.PERSONAL)
|
||||
.eventDate(LocalDate.of(1914, 7, 28))
|
||||
.createdBy(UUID.randomUUID())
|
||||
.updatedBy(UUID.randomUUID());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleting_linked_person_keeps_event_and_drops_join_row() {
|
||||
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
|
||||
TimelineEvent event = events.save(makeEvent().persons(Set.of(anna)).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
personService.deletePerson(anna.getId());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(event.getId())).isPresent();
|
||||
Integer joinRows = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM timeline_event_persons WHERE timeline_event_id = ?",
|
||||
Integer.class, event.getId());
|
||||
assertThat(joinRows).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleting_linked_document_keeps_event_and_drops_join_row() {
|
||||
Document letter = documentRepository.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
TimelineEvent event = events.save(makeEvent().documents(Set.of(letter)).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
documentService.deleteDocument(letter.getId(), UUID.randomUUID());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(event.getId())).isPresent();
|
||||
Integer joinRows = jdbc.queryForObject(
|
||||
"SELECT COUNT(*) FROM timeline_event_documents WHERE timeline_event_id = ?",
|
||||
Integer.class, event.getId());
|
||||
assertThat(joinRows).isZero();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentRepository;
|
||||
import org.raddatz.familienarchiv.document.DocumentStatus;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonRepository;
|
||||
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 org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Persistence + DB-constraint tests for {@link TimelineEvent} against real Postgres (V77).
|
||||
* Mirrors {@code MigrationIntegrationTest}'s slice setup; never H2 — only the real DB proves
|
||||
* enum-as-varchar storage, the RANGE/UNKNOWN CHECK constraints, FK cascade, and {@code @Version}.
|
||||
*/
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||
class TimelineEventTest {
|
||||
|
||||
@Autowired TimelineEventRepository events;
|
||||
@Autowired PersonRepository persons;
|
||||
@Autowired DocumentRepository documents;
|
||||
@Autowired EntityManager em;
|
||||
|
||||
/**
|
||||
* Sensible defaults; each test overrides only what it asserts. {@code createdBy}/{@code updatedBy}
|
||||
* default to random UUIDs — both columns are NOT NULL and not auto-populated, so without these
|
||||
* every test would fail at flush with the same constraint violation (red for the wrong reason).
|
||||
* Precision is intentionally left unset so {@code @Builder.Default YEAR} can be exercised.
|
||||
*/
|
||||
private TimelineEvent.TimelineEventBuilder makeEvent() {
|
||||
return TimelineEvent.builder()
|
||||
.title("Hochzeit von Anna und Otto")
|
||||
.type(EventType.PERSONAL)
|
||||
.eventDate(LocalDate.of(1914, 7, 28))
|
||||
.createdBy(UUID.randomUUID())
|
||||
.updatedBy(UUID.randomUUID());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persists_and_loads_event_with_required_fields() {
|
||||
TimelineEvent saved = events.save(makeEvent().build());
|
||||
|
||||
assertThat(saved.getId()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void precision_defaults_to_YEAR_when_not_set() {
|
||||
TimelineEvent saved = events.save(makeEvent().build());
|
||||
|
||||
assertThat(saved.getPrecision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void persists_event_with_linked_persons() {
|
||||
Person anna = persons.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
|
||||
TimelineEvent saved = events.save(makeEvent().persons(Set.of(anna)).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getPersons()).extracting(Person::getId).containsExactly(anna.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persists_event_with_linked_documents() {
|
||||
Document letter = documents.save(Document.builder()
|
||||
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
|
||||
TimelineEvent saved = events.save(makeEvent().documents(Set.of(letter)).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
|
||||
assertThat(reloaded.getDocuments()).extracting(Document::getId).containsExactly(letter.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventDateEnd_round_trips_null_for_non_range() {
|
||||
TimelineEvent saved = events.save(makeEvent().build()); // YEAR precision, no end
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventDateEnd_round_trips_value_for_range() {
|
||||
TimelineEvent saved = events.save(makeEvent()
|
||||
.precision(DatePrecision.RANGE)
|
||||
.eventDate(LocalDate.of(1914, 1, 1))
|
||||
.eventDateEnd(LocalDate.of(1918, 12, 31))
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd())
|
||||
.isEqualTo(LocalDate.of(1918, 12, 31));
|
||||
}
|
||||
|
||||
@Test
|
||||
void description_round_trips_null() {
|
||||
TimelineEvent saved = events.save(makeEvent().build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void description_round_trips_multi_kb_text() {
|
||||
// Proves TEXT has no length cap — @Column(columnDefinition = "TEXT") overrides
|
||||
// Hibernate's default VARCHAR(255).
|
||||
String longText = "Sommertage am See. ".repeat(500); // ~9.5 KB
|
||||
TimelineEvent saved = events.save(makeEvent().description(longText).build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isEqualTo(longText);
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
void range_invariant_rejects_non_null_end_without_range_precision() {
|
||||
// precision YEAR + non-null end violates chk_timeline_event_range.
|
||||
try {
|
||||
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
|
||||
.eventDateEnd(LocalDate.of(1918, 12, 31))
|
||||
.build()))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
} finally {
|
||||
events.deleteAll(); // NOT_SUPPORTED opts out of the rollback; clean any leaked row
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
void range_invariant_rejects_range_precision_without_end_date() {
|
||||
// precision RANGE + null end violates chk_timeline_event_range.
|
||||
try {
|
||||
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
|
||||
.precision(DatePrecision.RANGE)
|
||||
.build()))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
} finally {
|
||||
events.deleteAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
void unknown_precision_is_rejected() {
|
||||
// chk_timeline_event_precision forbids UNKNOWN — curated events are never undated.
|
||||
try {
|
||||
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
|
||||
.precision(DatePrecision.UNKNOWN)
|
||||
.build()))
|
||||
.isInstanceOf(DataIntegrityViolationException.class);
|
||||
} finally {
|
||||
events.deleteAll();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void version_is_null_before_persist_and_zero_after_save() {
|
||||
TimelineEvent fresh = makeEvent().build();
|
||||
assertThat(fresh.getVersion()).isNull(); // @Version Long is null pre-persist
|
||||
|
||||
TimelineEvent saved = events.saveAndFlush(fresh);
|
||||
assertThat(saved.getVersion()).isEqualTo(0L); // Hibernate sets 0 on insert
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = DatePrecision.class, names = {"DAY", "MONTH", "SEASON", "YEAR", "APPROX"})
|
||||
void all_non_unknown_precisions_are_accepted(DatePrecision precision) {
|
||||
// Accept-side of chk_timeline_event_precision: every non-RANGE, non-UNKNOWN value persists.
|
||||
// Documents that SEASON ("Sommer 1914") and APPROX ("ca. 1914") are intentionally legal,
|
||||
// so an over-tight CHECK cannot ship green. (RANGE is covered by the round-trip test.)
|
||||
TimelineEvent saved = events.saveAndFlush(makeEvent().precision(precision).build());
|
||||
|
||||
assertThat(saved.getId()).isNotNull();
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ Both stacks are organised **package-by-domain**: each domain owns its entities,
|
||||
|
||||
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
|
||||
|
||||
**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body).
|
||||
**`geschichte`** — family stories and Lesereisen. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle) and `JourneyItem` (document attachments / editorial notes shared by both subtypes — no application-level type guard). Two subtypes: `STORY` (prose + attached documents) and `JOURNEY` (ordered curated sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL). See ADR-037.
|
||||
|
||||
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
|
||||
|
||||
@@ -61,7 +61,7 @@ Members of the cross-cutting layer have no entity of their own, no user-facing C
|
||||
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
|
||||
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
|
||||
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). |
|
||||
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service. Adding a new `ErrorCode` requires matching updates in `frontend/src/lib/shared/errors.ts` and all three `messages/*.json` locale files. Current security-related codes: `CSRF_TOKEN_MISSING` (403 on mutating request without valid `X-XSRF-TOKEN` header), `TOO_MANY_LOGIN_ATTEMPTS` (429 when login rate limit exceeded). Journey/geschichte domain codes: `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE`, `GESCHICHTE_TITLE_TOO_LONG`, `GESCHICHTE_INTRO_TOO_LONG`. |
|
||||
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
|
||||
| `importing` | `CanonicalImportOrchestrator` — async canonical import running four idempotent loaders (`TagTreeImporter` → `PersonRegisterImporter` → `PersonTreeImporter` → `DocumentImporter`) over the normalizer's committed canonical artifacts (`canonical-*.xlsx` + `canonical-persons-tree.json`) | Orchestrates across `person`, `tag`, `document` |
|
||||
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
|
||||
|
||||
@@ -124,6 +124,8 @@ All vars are set in `.env` at the repo root (copy from `.env.example`). The back
|
||||
| `POSTGRES_PASSWORD` | DB password | `change-me` | YES | YES |
|
||||
| `POSTGRES_DB` | Database name | `family_archive_db` | YES | — |
|
||||
|
||||
> **PgBouncer pooling mode:** The `journey_items.position_seq` dedup constraint uses `DEFERRABLE INITIALLY DEFERRED`. This requires PgBouncer in **transaction-mode** (not statement-mode) pooling. Do not switch to statement-level pooling — deferred constraints only work within a single transaction session.
|
||||
|
||||
### MinIO container
|
||||
|
||||
| Variable | Purpose | Default | Required? | Sensitive? |
|
||||
@@ -516,6 +518,26 @@ docker exec -i archive-db psql -U ${POSTGRES_USER} ${POSTGRES_DB} < backup-YYYYM
|
||||
|
||||
Automated backup (nightly `pg_dump` + MinIO `mc mirror` over Tailscale to `heim-nas`) is a follow-up issue. Until that ships: **manual backups are the only recovery option.**
|
||||
|
||||
### Deploy note — V76 (persons birth/death → date + precision, #773)
|
||||
|
||||
V76 drops `persons.birth_year`/`death_year` after backfilling the new
|
||||
`birth_date`/`death_date` + precision columns — a **one-way migration** (Flyway cannot
|
||||
roll it back). Before deploying:
|
||||
|
||||
1. Take a manual `pg_dump` (see above) — there is no automated nightly backup yet, so
|
||||
confirm the dump completed before starting the deploy.
|
||||
2. No maintenance window is required: the pre-check + DDL run in one atomic Flyway
|
||||
transaction, and this single-writer archive has no concurrent importers during deploy.
|
||||
|
||||
If post-deploy data issues are found, restore **only the persons table** from the
|
||||
pre-migration dump (targeted restore, not a full-database restore):
|
||||
|
||||
```bash
|
||||
pg_restore -t persons -d ${POSTGRES_DB} backup-YYYYMMDD.dump
|
||||
```
|
||||
|
||||
(For a plain-SQL dump, extract the persons COPY block instead of replaying the full file.)
|
||||
|
||||
### Rollback
|
||||
|
||||
Each release tag corresponds to a docker image tag on the host daemon (built via DooD; no registry). Rolling back to a previous tag is one command:
|
||||
|
||||
@@ -149,7 +149,26 @@ _See also [Chronik](#chronik-internal)._
|
||||
|
||||
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
|
||||
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s and attaching documents via `journey_items`) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
|
||||
|
||||
**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a document attachment or editorial note belonging to a `Geschichte` of either subtype. JOURNEY-type Geschichten use items for their ordered reading sequence; STORY-type Geschichten use items to attach referenced documents (no type guard is enforced at the application layer — both subtypes share this table). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per Geschichte). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`). See ADR-037.
|
||||
|
||||
**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List<JourneyItemView>` loaded via a separate query rather than a lazy collection.
|
||||
|
||||
**JourneyItemView** (`JourneyItemView`) `[internal]` — lean view record for a single `JourneyItem` surface, containing `id`, `position`, an optional `DocumentSummary`, and an optional `note`.
|
||||
|
||||
**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info.
|
||||
|
||||
**Interlude / Zwischentext** `[user-facing]` — an editorial paragraph inserted between document items in a *Lesereise*. An interlude is a `JourneyItem` with `document_id IS NULL` and a non-empty `note`; its content is a plain-text string stored in the `note` column (not `body` or `text`). Visually distinguished by `--color-interlude-bg/border/label` CSS tokens and a `ZWISCHENTEXT` label. Interludes cannot have their note removed (removing the interlude deletes the entire item).
|
||||
_Not to be confused with a document item's optional note_ — a document item's note is curator commentary attached to a linked letter; an interlude is standalone editorial prose with no backing document.
|
||||
|
||||
**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience.
|
||||
|
||||
**TimelineEvent** (`TimelineEvent`, table `timeline_events`) `[internal]` — a curated event on the family timeline (*Zeitstrahl*), authored by a curator rather than OCR-derived. Carries the same date block as a `Document` (`eventDate` + `precision` + nullable `eventDateEnd`) so events and letters render through one path, plus a `title`, optional `description`, an `EventType`, and `ManyToMany` links to the `Person`s it involves and the `Document`s that support it (both join FKs `ON DELETE CASCADE`). Diverges from `Document` with an optimistic-lock `@Version` and a NOT NULL `createdBy`/`updatedBy` audit trail (bare UUIDs, no FK to `app_users`) for the multi-curator edit flow. Two DB CHECKs: `event_date_end` is non-null **iff** precision is `RANGE` (a strict biconditional, intentionally tighter than `Document`'s open-ended ranges), and `precision` is never `UNKNOWN` (a curated event always has at least a year; `SEASON`/`APPROX` stay legal). See ADR-040.
|
||||
|
||||
**EventType** (`EventType`) `[user-facing]` — the kind of a `TimelineEvent`: `PERSONAL` (a family event, rendered with the family accent) or `HISTORICAL` (world/historical context, rendered with a muted world accent). The string value names are a stable frontend styling contract — renaming requires a coordinated frontend change (ADR-040).
|
||||
|
||||
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s (and, in later issues, derived life-events) chronologically. The milestone home of the `timeline` domain.
|
||||
|
||||
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
|
||||
|
||||
|
||||
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
43
docs/adr/035-optional-string-three-way-patch-semantics.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# ADR-035 — `Optional<String>` for three-way PATCH semantics
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-08
|
||||
**Issue:** #751 (JourneyItem CRUD API)
|
||||
|
||||
## Context
|
||||
|
||||
The `PATCH /api/geschichten/{id}/items/{itemId}` endpoint must distinguish three cases for the `note` field:
|
||||
|
||||
| JSON body | Intended meaning |
|
||||
|-------------------|-----------------------|
|
||||
| `{"note": "text"}`| Set note to "text" |
|
||||
| `{"note": null}` | Clear the note |
|
||||
| `{}` (absent) | Leave note unchanged |
|
||||
|
||||
The standard library for this on Jackson 2.x is `jackson-databind-nullable` (`JsonNullable<T>` from `org.openapitools`). However, that library targets `com.fasterxml.jackson.*` (Jackson 2.x) and is incompatible with Spring Boot 4.0 / Spring Framework 7, which uses `tools.jackson.*` (Jackson 3.x). The module fails to register and throws at startup.
|
||||
|
||||
## Decision
|
||||
|
||||
Use `Optional<String>` with Java's default field initializer (`= null`) to encode the three states:
|
||||
|
||||
```java
|
||||
@Data
|
||||
public class JourneyItemUpdateDTO {
|
||||
private Optional<String> note = null; // Java default — absent = no-op
|
||||
}
|
||||
```
|
||||
|
||||
| Java value | JSON wire | Semantics |
|
||||
|--------------------|-------------------|---------------|
|
||||
| `null` (default) | field absent | no-op |
|
||||
| `Optional.empty()` | `{"note": null}` | clear |
|
||||
| `Optional.of("x")` | `{"note": "x"}` | set |
|
||||
|
||||
Jackson 3.x natively maps a JSON `null` to `Optional.empty()` and leaves absent fields at their Java default. No custom module is needed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No external dependency for PATCH semantics — simpler pom.xml.
|
||||
- The DTO field type is `Optional<String>`, not `String` — service code must null-check the field first (`if (noteField == null) return;`) and then call `.orElse(null)` to unwrap.
|
||||
- This pattern applies to any future PATCH DTO that needs three-way semantics on a nullable field.
|
||||
- `jackson-databind-nullable` is removed from `pom.xml`; `JacksonConfig.java` is kept as a placeholder for future custom modules.
|
||||
65
docs/adr/036-geschichte-responses-are-views-not-entities.md
Normal file
65
docs/adr/036-geschichte-responses-are-views-not-entities.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# ADR-036 — Geschichte responses are views assembled in-transaction, never entities
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-10
|
||||
**Issue:** #753 (JourneyEditor frontend), PR #792 review
|
||||
|
||||
## Context
|
||||
|
||||
The project convention (CLAUDE.md §DTOs) has been: *"Response types are the model
|
||||
entities themselves (no response DTOs)."* That convention assumed entities whose
|
||||
associations are either eager or initialized by the time Jackson serializes.
|
||||
|
||||
The lazy-fetch migration (ADR-022, `open-in-view: false`) broke that assumption:
|
||||
Jackson serializes **after** the service transaction has closed, so any lazy
|
||||
collection on a returned entity is a dead proxy. `Geschichte.items` (added with the
|
||||
Lesereisen data model, #750) made this concrete: every `PATCH /api/geschichten/{id}`
|
||||
(save draft, publish) failed with HTTP 500
|
||||
`LazyInitializationException: Geschichte.items … (no session)`.
|
||||
|
||||
Per-endpoint force-initialization (`g.getItems().size()` inside the transaction)
|
||||
worked for `getById()` but is a footgun: every new write method must remember the
|
||||
trick, the entity carries a warning comment nobody reads, and the raw entity also
|
||||
leaks the `author` `AppUser` graph (email, password hash, groups).
|
||||
|
||||
## Decision
|
||||
|
||||
In the **geschichte domain**, controllers never return entities. Every response is a
|
||||
purpose-built read model assembled **inside** the service transaction:
|
||||
|
||||
- `GET /api/geschichten` → `GeschichteSummary` (projection; never carries items;
|
||||
author exposes names only — never email)
|
||||
- `GET /api/geschichten/{id}` → `GeschichteView` (with `AuthorView`, `PersonView`,
|
||||
`JourneyItemView` items)
|
||||
- `POST /api/geschichten`, `PATCH /api/geschichten/{id}` → `GeschichteView`
|
||||
- JourneyItem endpoints → `JourneyItemView`
|
||||
|
||||
The invariant: **entities never cross the controller boundary in this domain.**
|
||||
A view is constructed while the Hibernate session is open, so serialization can
|
||||
never touch a lazy proxy, and the response shape is an explicit, security-reviewed
|
||||
contract.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **`@Transactional` on read/write methods + force-init (`getItems().size()`)** —
|
||||
fixes one endpoint at a time, silently regresses when the next write method is
|
||||
added, and still serializes the raw `AppUser` author graph.
|
||||
- **`open-in-view: true`** — re-opens the session during rendering; hides N+1
|
||||
queries and couples the HTTP layer to Hibernate session lifetime. Rejected
|
||||
already by ADR-022.
|
||||
- **Jackson `@JsonIgnore` on lazy fields** — loses the data the client needs
|
||||
(items ARE the journey) instead of loading it deliberately.
|
||||
|
||||
## Consequences
|
||||
|
||||
- CLAUDE.md §DTOs names the geschichte domain as the exception to the
|
||||
entities-as-responses convention. Other domains (document, person, tag) still
|
||||
return entities; they predate ADR-022's lazy collections on their hot paths and
|
||||
migrate opportunistically when they grow lazy collections of their own.
|
||||
- `npm run generate:api` must run after any view change — the generated
|
||||
`Geschichte` schema no longer exists; frontend consumers use
|
||||
`GeschichteView`/`GeschichteSummary`.
|
||||
- New geschichte endpoints must add a view (or extend an existing one), not return
|
||||
the entity. The regression guards are `GeschichteHttpTest`
|
||||
(`update_returns_200_and_serializes_items_open_in_view_false`) and
|
||||
`GeschichteListProjectionTest`.
|
||||
78
docs/adr/037-journey-items-serve-both-geschichte-subtypes.md
Normal file
78
docs/adr/037-journey-items-serve-both-geschichte-subtypes.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# ADR-037 — `journey_items` serves both STORY and JOURNEY Geschichte subtypes
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-11
|
||||
**Issue:** #795 (restore document management for STORY-type Geschichten), PR #804 review
|
||||
|
||||
## Context
|
||||
|
||||
V72 added the `journey_items` table as the backing store for Lesereisen (JOURNEY-type
|
||||
Geschichten). At the same time, the previous `geschichten_documents` join table was
|
||||
dropped (#753) and the restoration of a STORY-level document attachment mechanism was
|
||||
deferred to a future issue.
|
||||
|
||||
`JourneyItemService.append()` contained an application-level type guard that rejected
|
||||
`append()` calls on STORY-type Geschichten with `GESCHICHTE_TYPE_MISMATCH`. This guard
|
||||
was the only place where the STORY restriction was encoded — the database schema never
|
||||
enforced it (no CHECK constraint, no partial index on `type='JOURNEY'`).
|
||||
|
||||
When #795 restored document attachment for STORY-type Geschichten, the type guard was
|
||||
the only obstacle. Two implementation paths were considered:
|
||||
|
||||
1. Keep an allowlist (`if type not in (JOURNEY, STORY) throw ...`) — dead code today
|
||||
because `GeschichteType` is a two-constant enum; the branch can never be reached and
|
||||
would fail the JaCoCo branch-coverage gate.
|
||||
2. Delete the guard entirely — the schema never encoded the restriction; deleting dead
|
||||
application logic rather than replacing it with more dead logic.
|
||||
|
||||
Path 2 was chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
`journey_items` is the document-attachment mechanism for **both** `STORY` and `JOURNEY`
|
||||
subtypes. No application-level type guard governs which subtype may hold items. The only
|
||||
behavioral difference between the two subtypes' use of items is at the UI layer:
|
||||
|
||||
- JOURNEY: items form an ordered reading sequence rendered as a *Lesereise*.
|
||||
- STORY: items are a set of attached reference documents rendered as a sidebar panel.
|
||||
|
||||
Both subtypes share the same capacity cap (100 items), dedup index, position semantics,
|
||||
and DEFERRABLE constraint — enforced at the database layer, not re-implemented per subtype.
|
||||
|
||||
The `GESCHICHTE_TYPE_MISMATCH` error code was removed end-to-end (backend enum,
|
||||
frontend `ErrorCode` type + `getErrorMessage()` case, all three locale files).
|
||||
`GESCHICHTE_TYPE_IMMUTABLE` is unrelated and was left intact.
|
||||
|
||||
## Naming asymmetry (intentional)
|
||||
|
||||
The error codes `JOURNEY_AT_CAPACITY` and `JOURNEY_DOCUMENT_ALREADY_ADDED` carry
|
||||
journey-flavored names. Renaming them would ripple through `ErrorCode.java`, `errors.ts`,
|
||||
and three locale files for zero behavior change. `StoryDocumentPanel` remaps these two
|
||||
codes to story-worded user messages at the presentation layer — the asymmetry is a
|
||||
documented decision, not an accident.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **Separate `story_documents` join table for STORY** — creates two nearly-identical
|
||||
schemas for the same concept (document attachment with dedup and ordering), doubles the
|
||||
migration surface, and splits the capacity/dedup logic. Rejected as unnecessary
|
||||
duplication.
|
||||
- **Allowlist type guard (`if type not in (JOURNEY, STORY)`)** — unreachable dead code
|
||||
under a two-constant enum; fails the JaCoCo branch gate. Rejected.
|
||||
- **Per-subtype application validation** — the schema never encoded the restriction; an
|
||||
application-only rule with no schema backing is the weakest kind of invariant and was
|
||||
removed when the product decision reversed it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `JourneyItemService.append()` accepts items for any `Geschichte`, regardless of subtype.
|
||||
The 100-item cap and dedup constraint apply to all.
|
||||
- GLOSSARY.md and ARCHITECTURE.md updated to reflect that `JourneyItem` is not
|
||||
JOURNEY-specific.
|
||||
- The `l3-backend-3g-supporting.puml` C4 diagram updated: type-guard language removed,
|
||||
`geschQuerySvc` rel label reads "Checks Geschichte existence" (not "and type").
|
||||
- `StoryDocumentPanel.svelte` is the STORY-side consumer; `JourneyEditor.svelte` is the
|
||||
JOURNEY-side consumer. Neither is aware of the other.
|
||||
- Known pre-existing constraint conflict: `ON DELETE SET NULL` on `journey_items.document_id`
|
||||
combined with `chk_journey_item_not_empty` causes a DB-level 500 when a document linked
|
||||
via a note-less item is deleted. Pre-existing; tracked in follow-up issue.
|
||||
118
docs/adr/038-domain-event-driven-journey-item-cleanup.md
Normal file
118
docs/adr/038-domain-event-driven-journey-item-cleanup.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# ADR-038 — Domain event drives note-less journey-item cleanup on document delete
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-11
|
||||
**Issue:** #805 (P1 — deleting a document linked via a note-less journey_item 500s at DB constraint)
|
||||
|
||||
## Context
|
||||
|
||||
Two constraints in V72 encode contradictory rules for a journey item that has a
|
||||
`document_id` but no `note`:
|
||||
|
||||
- **`fk_journey_items_document` → `ON DELETE SET NULL`** — when a document is deleted,
|
||||
Postgres nulls out `document_id`.
|
||||
- **`chk_journey_item_not_empty`** — requires at least one of `document_id` or `note`
|
||||
to be non-null.
|
||||
|
||||
A note-less item (`document_id` set, `note IS NULL`) satisfies the CHECK while the
|
||||
document exists. Deleting the document causes Postgres to attempt `SET NULL`, which
|
||||
would leave both columns null — a direct CHECK violation. Postgres aborts the
|
||||
transaction with a 500 that bypasses `GlobalExceptionHandler`.
|
||||
|
||||
The natural fix — delete note-less items inside `DocumentService.deleteDocument` before
|
||||
`deleteById` runs — cannot call `JourneyItemService` directly: `JourneyItemService`
|
||||
already injects `DocumentService`, and Spring Framework 7 (used by Spring Boot 4)
|
||||
**fully prohibits constructor-injection cycles**. The application will not start if such
|
||||
a cycle is introduced.
|
||||
|
||||
## Decision
|
||||
|
||||
`DocumentService.deleteDocument` publishes a **`DocumentDeletingEvent`** (plain record,
|
||||
payload: `documentId` UUID only) via `ApplicationEventPublisher` **before**
|
||||
`documentRepository.deleteById`. A dedicated `@Component`
|
||||
`JourneyItemDocumentDeleteListener` in the `geschichte.journeyitem` package consumes
|
||||
this event and calls `journeyItemRepository.deleteNoteLessByDocumentId(documentId)`
|
||||
directly — bypassing `JourneyItemService` to avoid re-introducing the cycle and to
|
||||
suppress the per-item `JOURNEY_ITEM_REMOVED` audit emission (see audit decision below).
|
||||
|
||||
### Load-bearing listener-phase choice: plain `@EventListener`
|
||||
|
||||
The listener is annotated with `@EventListener` (not
|
||||
`@TransactionalEventListener(AFTER_COMMIT)`, not `@Async`). **This choice is
|
||||
load-bearing:**
|
||||
|
||||
- **`AFTER_COMMIT` would break the fix entirely.** `AFTER_COMMIT` fires *after* the
|
||||
surrounding transaction has committed. By that point, `documentRepository.deleteById`
|
||||
has already executed and Postgres has already tried `ON DELETE SET NULL` — the
|
||||
constraint violation fires before the listener ever runs.
|
||||
- **`@Async` would break rollback atomicity (AC-5).** An async listener runs on a
|
||||
separate thread in its own transaction. If `deleteDocument` subsequently rolls back
|
||||
(e.g. due to an unrelated failure), the listener's deletes are in a committed async
|
||||
transaction and cannot be undone.
|
||||
- **Plain `@EventListener` runs synchronously in the publisher's thread and
|
||||
transaction.** The listener's JPQL delete and the `deleteById` are a single atomic
|
||||
unit: if either fails, both roll back together.
|
||||
|
||||
### Repository method
|
||||
|
||||
```java
|
||||
@Modifying(clearAutomatically = true)
|
||||
@Query("DELETE FROM JourneyItem i WHERE i.document.id = :documentId AND (i.note IS NULL OR i.note = '')")
|
||||
int deleteNoteLessByDocumentId(@Param("documentId") UUID documentId);
|
||||
```
|
||||
|
||||
`i.document.id` (the real association path) is used instead of `i.documentId`: the
|
||||
transient `getDocumentId()` getter on `JourneyItem` makes Spring Data unable to resolve
|
||||
a derived query path (same trap documented at `JourneyItemRepository:33-44`).
|
||||
|
||||
`clearAutomatically = true` invalidates the L1 cache so subsequent reads in the same
|
||||
session do not return stale entities.
|
||||
|
||||
The predicate `(note IS NULL OR note = '')` covers the `note = ''` edge case that the
|
||||
service layer can never produce (normalizeNote converts blank strings to null), but that
|
||||
may exist via raw SQL inserts or legacy data. Whitespace-only notes (`note = ' '`)
|
||||
do not match and are preserved as note-carrying placeholders.
|
||||
|
||||
### Audit decision
|
||||
|
||||
The listener calls the repository directly rather than routing through
|
||||
`JourneyItemService.delete`. This deliberately bypasses the `JOURNEY_ITEM_REMOVED`
|
||||
audit emission: a document used in multiple journeys would otherwise produce N audit
|
||||
rows for a single user action. The `DOCUMENT_DELETED` entry written by `deleteDocument`
|
||||
is the sole audit record for the operation.
|
||||
|
||||
### Boundary: documents must not depend on journey
|
||||
|
||||
The event direction is `document → journey`, never the reverse. `DocumentService`
|
||||
publishes events it knows nothing about the consumers of; `JourneyItemService`'s
|
||||
dependency on `DocumentService` is unchanged and remains the only cross-domain
|
||||
reference. This direction is the prerequisite for the cycle constraint to hold.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **DB trigger on `journey_items`** — trigger logic is opaque to Java developers,
|
||||
invisible to code review, and not covered by the JPA test harness.
|
||||
- **RESTRICT instead of SET NULL** — breaks the existing note-carrying placeholder
|
||||
UX: deleting a document with a note-carrying journey item would 409 instead of
|
||||
preserving the item as a placeholder.
|
||||
- **Relax `chk_journey_item_not_empty`** — the constraint enforces a real invariant
|
||||
(every item must have at least document or note). Removing it would allow empty rows.
|
||||
- **`@Lazy` on the `JourneyItemService → DocumentService` injection** — Spring Boot 4 /
|
||||
Spring Framework 7 prohibits constructor-injection cycles regardless of `@Lazy`.
|
||||
- **Make `DocumentService` call `JourneyItemService`** — introduces the prohibited
|
||||
cycle. Rejected at design time.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **No schema change** — no new Flyway migration, no `db-orm.puml` /
|
||||
`db-relationships.puml` update.
|
||||
- This is the **first custom domain event** in the codebase. No prior
|
||||
`ApplicationEventPublisher` usage existed in `main/`. New cross-domain cleanup that
|
||||
cannot use direct service calls should follow this pattern.
|
||||
- All tests that delete documents and then assert journey-item state **must route
|
||||
through `DocumentService.deleteDocument`**, not `documentRepository.deleteById`.
|
||||
The existing `JourneyItemIntegrationTest` tests that covered the note-carrying
|
||||
placeholder UX have been updated accordingly.
|
||||
- The `DOCUMENT_DELETED` `AuditKind` was added as part of this fix to give AC-7's
|
||||
audit assertion a positive check (absence-only assertions pass vacuously if all
|
||||
auditing regresses).
|
||||
98
docs/adr/039-person-life-dates-localdate-precision.md
Normal file
98
docs/adr/039-person-life-dates-localdate-precision.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# ADR-039 — Person life dates become LocalDate + DatePrecision
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-12
|
||||
**Issue:** #773 (Zeitstrahl milestone, foundational)
|
||||
|
||||
## Context
|
||||
|
||||
`Person` stored `birthYear`/`deathYear` as `Integer`. A known exact birthday
|
||||
(`1901-03-14`) had nowhere to live, and every display was stuck at year precision.
|
||||
The Zeitstrahl's derived life-events need real dates with precision metadata, and the
|
||||
document domain already solved exactly this problem with
|
||||
`Document.documentDate` + `metaDatePrecision`.
|
||||
|
||||
V76 replaces the two integer columns with `birth_date`/`death_date` (`DATE`, nullable)
|
||||
plus `birth_date_precision`/`death_date_precision` (`VARCHAR(16) NOT NULL DEFAULT
|
||||
'UNKNOWN'`), backfilling existing years as `YYYY-01-01` at `YEAR` precision.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. `DatePrecision` stays in `document/` and is imported cross-domain
|
||||
|
||||
The `person` package imports `org.raddatz.familienarchiv.document.DatePrecision`
|
||||
directly. The layering rule (controllers → services → own repository) governs
|
||||
service-to-repository coupling, **not** value-type sharing — an enum has no behaviour
|
||||
and no persistence side effects. Creating a `common/` package for one enum would be
|
||||
premature structure; if a third domain needs it, revisit then. The enum remains a
|
||||
verbatim mirror of the import normalizer's `Precision` values (ADR-025) — changes must
|
||||
stay in sync with `tools/import-normalizer/dates.py`.
|
||||
|
||||
### 2. Precision columns are NOT NULL with default `UNKNOWN`
|
||||
|
||||
Mirrors `Document.metaDatePrecision`. The illegal state "date present, precision null"
|
||||
cannot exist: the named CHECK constraints
|
||||
(`chk_person_birth_date_precision_coherence`, `…_values`,
|
||||
`chk_person_birth_before_death`, and the death-side twins) enforce
|
||||
`(date IS NULL) = (precision = 'UNKNOWN')` and the temporal order at the DB level;
|
||||
`PersonService.validateLifeDates` enforces the same rules first so users get a
|
||||
structured 400 (`INVALID_DATE_PRECISION` / `BIRTH_AFTER_DEATH`) instead of a
|
||||
constraint-violation 500.
|
||||
|
||||
Storage accepts all seven `DatePrecision` values (enum-to-string mapping consistency),
|
||||
but the person new/edit form offers only **DAY / MONTH / YEAR** — `RANGE` and `SEASON`
|
||||
are semantically nonsensical for a birth or death, and `APPROX` is excluded from the
|
||||
form to reduce cognitive load for the senior author audience. Legacy `APPROX` rows
|
||||
still render correctly (display delegates to `formatDocumentDate`).
|
||||
|
||||
The edit form seeds a stored non-offered precision (`APPROX`/`SEASON`/`RANGE`) into
|
||||
the select as `YEAR`, so an untouched save coerces it to `YEAR` ("ca. 1944" becomes
|
||||
"1944"). Accepted: nothing currently writes those precisions to persons (the form
|
||||
offers DAY/MONTH/YEAR, the importer writes YEAR/UNKNOWN, V76 backfills YEAR), so the
|
||||
case is only reachable via direct API writes — and seeding `YEAR` is strictly safer
|
||||
than the alternative of silently claiming `DAY` precision.
|
||||
|
||||
### 3. Derived-year pattern for backward-compatible DTOs
|
||||
|
||||
`PersonNodeDTO` (Stammbaum) and `RelationshipDTO` keep `Integer birthYear/deathYear`,
|
||||
derived null-safely in the relationship services (`birthDate != null ?
|
||||
birthDate.getYear() : null` — never 0, never empty string; REQ-PERSON-DATE-01). The
|
||||
native queries behind `PersonSummaryDTO` project
|
||||
`CAST(EXTRACT(YEAR FROM p.birth_date) AS int) AS birthYear`.
|
||||
|
||||
### 4. `PersonSummaryDTO` intentionally exposes years only
|
||||
|
||||
The person list/search views show year precision only; full precision lives on the
|
||||
person detail page. Do **not** add `LocalDate getBirthDate()` to the interface without
|
||||
updating all four native queries (`findAllWithDocumentCount`,
|
||||
`searchWithDocumentCount`, `findTopByDocumentCount`, `findByFilter`) — the interface
|
||||
projection is satisfied purely by the SQL SELECT aliases.
|
||||
|
||||
### 5. `preferHumanDate` extends ADR-025's human-edit-preserve rule
|
||||
|
||||
The importer stays year-shaped: `PersonUpsertCommand` keeps `Integer birthYear/
|
||||
deathYear` because the spreadsheet only knows a year — pushing `LocalDate` into the
|
||||
importer would fabricate precision. On upsert, `PersonService.preferHumanDate` returns
|
||||
a `DatePrecisionPair` record (date and precision travel as one value so they cannot go
|
||||
out of sync):
|
||||
|
||||
- existing precision DAY/MONTH/SEASON/RANGE/APPROX → hand-entered, preserved verbatim;
|
||||
- existing precision YEAR/UNKNOWN → refreshed from the canonical year as
|
||||
`YYYY-01-01` + `YEAR` (or cleared to null/`UNKNOWN` when the sheet has no year).
|
||||
|
||||
The original integer/string `preferHuman` overloads remain for non-date fields.
|
||||
|
||||
### 6. Known limitation — mixed-precision comparison
|
||||
|
||||
`validateLifeDates` compares stored `LocalDate` values. A DAY-precision birth late in
|
||||
the same year as a YEAR-precision death (stored as Jan 1st) is rejected with
|
||||
`BIRTH_AFTER_DEATH`. This is intentional; the `error_birth_after_death` i18n message
|
||||
carries the workaround hint (enter the following year as the death year).
|
||||
|
||||
## Consequences
|
||||
|
||||
- V76 is one-way (columns dropped). Rollback = targeted `pg_restore -t persons` from
|
||||
the pre-deploy dump — see `docs/DEPLOYMENT.md` §5.
|
||||
- Exact dates now render on person cards, hover cards, and the mention dropdown; the
|
||||
Stammbaum and person list are visually unchanged.
|
||||
- The Zeitstrahl can derive birth/death life-events at full precision.
|
||||
115
docs/adr/040-timeline-domain-data-model.md
Normal file
115
docs/adr/040-timeline-domain-data-model.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# ADR-040 — Timeline domain data model
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-06-12
|
||||
**Issue:** #774 (Zeitstrahl milestone, foundational)
|
||||
|
||||
## Context
|
||||
|
||||
The Zeitstrahl (family timeline) needs a home for *curated* events — births,
|
||||
weddings, moves, and world-historical context a curator types in by hand, distinct
|
||||
from the OCR-derived `Document` letters. This ADR commits the new `timeline` domain's
|
||||
data model: the `TimelineEvent` entity, the `EventType` enum, a repository, and the
|
||||
V77 migration. No service, controller, or DTO ships here — those land in later issues.
|
||||
|
||||
A `TimelineEvent` carries the same date block as `Document` (`eventDate` +
|
||||
`precision` + `eventDateEnd`) so events and letters render through one path, but its
|
||||
audit footprint deliberately diverges (see below).
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. New `timeline` domain package, separate from `geschichte`
|
||||
|
||||
Curated timeline events are their own concern, not a Lesereise/`Geschichte` subtype.
|
||||
The domain owns `TimelineEvent`, `EventType`, and `TimelineEventRepository`.
|
||||
|
||||
### 2. Responses (#775) will be views, not serialized entities
|
||||
|
||||
`TimelineEvent` has two LAZY `ManyToMany` collections (`persons`, `documents`) and
|
||||
`open-in-view` is `false` — exactly the shape that motivated ADR-036 for `geschichte`.
|
||||
#775 must assemble `TimelineEventView`/`TimelineEventSummary` inside the service
|
||||
transaction; a serialized entity is a 500 waiting to happen. Decided up front so it is
|
||||
not retrofitted later.
|
||||
|
||||
### 3. `precision` reuses `document.DatePrecision` — imported, not duplicated
|
||||
|
||||
The `timeline` package imports `org.raddatz.familienarchiv.document.DatePrecision`
|
||||
directly, the same cross-domain value-type sharing ADR-039 established for `person`.
|
||||
An enum has no behaviour and no persistence side effects, so the layering rule
|
||||
(services → own repository) does not govern it. The enum stays a verbatim mirror of the
|
||||
import normalizer's `Precision` values (ADR-025); changes must stay in sync with
|
||||
`tools/import-normalizer/dates.py`. Moving `DatePrecision` into a shared package is a
|
||||
wider refactor (touching `Document`, `importing`, `person`) and its own future ADR.
|
||||
|
||||
### 4. `precision = UNKNOWN` is forbidden; every other value is legal
|
||||
|
||||
`eventDate` is NOT NULL — a curated event always has at least a year, so only OCR letters
|
||||
fall into the "Ohne Datum" bucket. The CHECK `chk_timeline_event_precision`
|
||||
(`date_precision <> 'UNKNOWN'`) forbids exactly that one value. `SEASON` ("Sommer 1914")
|
||||
and `APPROX` ("ca. 1914") are explicitly legal — family memory is full of both, and the
|
||||
spec's rendering table covers them. Do not narrow the CHECK to an allow-list; an
|
||||
over-tight constraint would force curators to fake `YEAR` and render dishonest dates.
|
||||
|
||||
### 5. RANGE invariant is a strict biconditional at the DB, intentionally tighter than `Document`
|
||||
|
||||
`chk_timeline_event_range` enforces `(date_precision = 'RANGE') = (event_date_end IS NOT
|
||||
NULL)` — `eventDateEnd` is non-null **iff** precision is `RANGE`, both directions. This is
|
||||
*stricter* than `Document`'s open-ended ranges (which allow a null end on a RANGE) because a
|
||||
curated event always has a known, closed end when it spans a range — it is authored, not
|
||||
inferred. This divergence is deliberate: a future "bug fix" must not relax it to match
|
||||
`Document`.
|
||||
|
||||
### 6. Audit trail: `@Version` + NOT NULL `createdBy`/`updatedBy`, diverging from `Document`
|
||||
|
||||
`Document` has neither a version nor a creator. A curated entity edited by multiple curators
|
||||
warrants real protection, so `TimelineEvent` adds:
|
||||
|
||||
- `@Version Long version` — optimistic locking for the multi-curator edit flow (#775).
|
||||
Object `Long` (not primitive) so it is `null` before first persist; Hibernate sets `0` on
|
||||
insert. The service **must** catch `ObjectOptimisticLockingFailureException` and translate
|
||||
it to `DomainException.conflict(...)`. Without that translation a concurrent-write conflict
|
||||
surfaces as HTTP 500 with Hibernate internals in the body — information disclosure (CWE-209).
|
||||
- `createdBy`/`updatedBy` as bare `UUID`, `NOT NULL`, no FK to `app_users` (sidecar pattern,
|
||||
matching `DocumentAnnotation`/`OcrJob`; keeps `timeline` decoupled from `user`, avoids
|
||||
lazy-load surprises in the read-heavy assembly path). NOT NULL makes a curated event with no
|
||||
author impossible — an audit gap closed at the schema level. `DocumentAnnotation.createdBy`
|
||||
is nullable and has no `updatedBy`; the escalation here is deliberate because curated events
|
||||
are multi-author. Curator display names resolve through `UserService` at render time.
|
||||
|
||||
`updatedBy` is **not** advanced by `@UpdateTimestamp` — the service must set it from the
|
||||
session principal before every `save()`, or the timestamp moves while the "who" goes stale.
|
||||
|
||||
### 7. `createdBy`/`updatedBy` are server-populated only — never bound from client input
|
||||
|
||||
Both are set from the session principal in the service, never from a request body. Binding
|
||||
them from client input is an authorship-forgery / mass-assignment vector (CWE-639). #775's
|
||||
regression suite must include forgery cases on **both** write paths (`POST` body with
|
||||
`createdBy`, `PUT` body with `updatedBy`) — create and update are separate binding paths, so
|
||||
testing only one leaves half the vector open. The update test must assert `updatedBy` equals
|
||||
the *second* editor's UUID, not merely non-null.
|
||||
|
||||
### 8. `EventType` string values are a stable frontend styling contract
|
||||
|
||||
The Tailwind class map in the timeline Svelte components hard-codes `PERSONAL` (family accent)
|
||||
and `HISTORICAL` (muted world accent) as strings. There is no mapping layer — renaming either
|
||||
value requires a coordinated frontend change. Recorded here to prevent a silent regression.
|
||||
|
||||
### 9. Explicit `@JoinTable` on both ManyToMany fields
|
||||
|
||||
Without explicit `@JoinTable(name, joinColumns, inverseJoinColumns)`, Hibernate's naming
|
||||
strategy could diverge from the V77 DDL's explicit table/column names. Explicit mapping
|
||||
guarantees alignment and makes future column renames a deliberate, visible change. All four FK
|
||||
columns are `ON DELETE CASCADE`: deleting a Person or Document drops the join row and leaves
|
||||
the event intact (V71/ADR-032 hardening — a person delete must never 500).
|
||||
|
||||
## Consequences
|
||||
|
||||
- V77 is forward-only; rollback is manual DDL (`DROP TABLE` the two join tables, then
|
||||
`timeline_events`). No rollback script, no rollback test.
|
||||
- The `timeline → document.DatePrecision` compile coupling is permanent until a shared-package
|
||||
refactor; precedent already exists (`importing/DocumentImporter`, `person`).
|
||||
- The service/controller/DTO layer (#775) inherits the view-assembly, optimistic-lock
|
||||
translation, forgery-guard, and permission obligations recorded above. It must also add a
|
||||
service-level title-length check (new `ErrorCode`, e.g. `TIMELINE_TITLE_TOO_LONG`, mirroring
|
||||
`GESCHICHTE_TITLE_TOO_LONG`) — `title` is `VARCHAR(255)`, and without the guard an over-long
|
||||
title surfaces as a raw `DataIntegrityViolationException` → HTTP 500.
|
||||
@@ -16,8 +16,11 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.")
|
||||
Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.")
|
||||
Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.")
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.")
|
||||
Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories (STORY) and reading journeys (JOURNEY). Returns GeschichteSummary projections for list; full Geschichte with JourneyItems for detail. Requires BLOG_WRITE permission for write operations.")
|
||||
Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Supports two subtypes: STORY (prose) and JOURNEY (ordered JourneyItem sequence). Sanitizes HTML body with an allowlist policy.")
|
||||
Component(geschQuerySvc, "GeschichteQueryService", "Spring Service", "Read-only facade over GeschichteRepository. Exposes existsById() and findById() to prevent JourneyItemService from crossing domain boundaries.")
|
||||
Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap). Serves both STORY and JOURNEY subtypes.")
|
||||
Component(journeyListener, "JourneyItemDocumentDeleteListener", "Spring @EventListener", "Consumes DocumentDeletingEvent synchronously inside the delete transaction and removes note-less journey items before ON DELETE SET NULL fires, preventing a chk_journey_item_not_empty violation. See ADR-038.")
|
||||
Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.")
|
||||
}
|
||||
|
||||
@@ -38,6 +41,12 @@ Rel(notifCtrl, notifSvc, "Delegates to")
|
||||
Rel(notifCtrl, sseRegistry, "Registers client SSE connection")
|
||||
Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients")
|
||||
Rel(geschCtrl, geschSvc, "Delegates to")
|
||||
Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD")
|
||||
Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence")
|
||||
Rel(geschQuerySvc, db, "Reads geschichten", "JDBC")
|
||||
Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC")
|
||||
Rel(documentSvc, journeyListener, "DocumentDeletingEvent", "in-process event")
|
||||
Rel(journeyListener, db, "Deletes note-less journey_items", "JDBC")
|
||||
Rel(auditSvc, db, "Writes audit_log", "JDBC")
|
||||
Rel(auditQuery, db, "Reads audit_log", "JDBC")
|
||||
Rel(notifSvc, db, "Reads / writes notifications", "JDBC")
|
||||
|
||||
24
docs/architecture/c4/l3-backend-timeline.puml
Normal file
24
docs/architecture/c4/l3-backend-timeline.puml
Normal file
@@ -0,0 +1,24 @@
|
||||
@startuml
|
||||
!include <C4/C4_Component>
|
||||
|
||||
title Component Diagram: API Backend — Timeline (Zeitstrahl)
|
||||
|
||||
ContainerDb(db, "PostgreSQL", "PostgreSQL 16")
|
||||
|
||||
System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents). Issue #774 ships the repository empty; the per-person filter query lands in #777.")
|
||||
|
||||
Component(timelineSvc, "TimelineEventService", "Spring Service (planned, #775)", "Will own curated-event CRUD: assemble TimelineEventView/Summary inside the transaction (lazy ManyToMany + open-in-view=false, per ADR-036/ADR-040), populate createdBy/updatedBy from the session principal, and translate optimistic-lock conflicts to DomainException.conflict.")
|
||||
Component(timelineCtrl, "TimelineEventController", "Spring MVC (planned, #775)", "Will expose /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
|
||||
}
|
||||
|
||||
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters")
|
||||
System_Ext(personDomain, "Person domain", "Provides Person references for who an event involves")
|
||||
|
||||
Rel(timelineRepo, db, "SQL queries", "JDBC")
|
||||
Rel(timelineSvc, timelineRepo, "Reads / writes events (planned)")
|
||||
Rel(timelineCtrl, timelineSvc, "Delegates to (planned)")
|
||||
Rel(timelineRepo, personDomain, "References persons via join table")
|
||||
Rel(timelineRepo, documentDomain, "References documents via join table")
|
||||
|
||||
@enduml
|
||||
@@ -11,8 +11,8 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
|
||||
Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.")
|
||||
Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.")
|
||||
Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.")
|
||||
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
|
||||
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
|
||||
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
|
||||
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
|
||||
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
|
||||
@@ -24,8 +24,8 @@ Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearc
|
||||
Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON")
|
||||
Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON")
|
||||
Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
|
||||
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
|
||||
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
|
||||
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
|
||||
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
|
||||
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@startuml db-orm
|
||||
' Schema source: Flyway V1–V69 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V69 (2026-05-27)
|
||||
' Schema source: Flyway V1–V77 (excl. V37, V43 — intentionally removed)
|
||||
' Schema as of: V77 (2026-06-12)
|
||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||
|
||||
hide circle
|
||||
@@ -184,8 +184,10 @@ package "Persons" {
|
||||
title : VARCHAR(50)
|
||||
person_type : VARCHAR(20) NOT NULL
|
||||
notes : TEXT
|
||||
birth_year : INTEGER
|
||||
death_year : INTEGER
|
||||
birth_date : DATE
|
||||
birth_date_precision : VARCHAR(16) NOT NULL
|
||||
death_date : DATE
|
||||
death_date_precision : VARCHAR(16) NOT NULL
|
||||
generation : SMALLINT
|
||||
family_member : BOOLEAN NOT NULL
|
||||
source_ref : VARCHAR(255) UNIQUE
|
||||
@@ -357,8 +359,9 @@ package "Supporting" {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
title : VARCHAR(255) NOT NULL
|
||||
body : TEXT
|
||||
body : TEXT CHECK (JOURNEY: length <= 4000)
|
||||
status : VARCHAR(32) NOT NULL
|
||||
type : VARCHAR(32) NOT NULL
|
||||
author_id : UUID <<FK>>
|
||||
created_at : TIMESTAMP NOT NULL
|
||||
updated_at : TIMESTAMP NOT NULL
|
||||
@@ -370,9 +373,49 @@ package "Supporting" {
|
||||
person_id : UUID <<FK>>
|
||||
}
|
||||
|
||||
entity geschichten_documents {
|
||||
entity journey_items {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
geschichte_id : UUID <<FK>>
|
||||
document_id : UUID <<FK>>
|
||||
position : INTEGER NOT NULL CHECK (position > 0)
|
||||
note : TEXT CHECK (length <= 2000)
|
||||
==
|
||||
UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED
|
||||
UNIQUE (geschichte_id, document_id) WHERE document_id IS NOT NULL
|
||||
}
|
||||
}
|
||||
|
||||
' ── Timeline (Zeitstrahl) ──
|
||||
package "Timeline" {
|
||||
|
||||
entity timeline_events {
|
||||
id : UUID <<PK>>
|
||||
--
|
||||
title : VARCHAR(255) NOT NULL
|
||||
type : VARCHAR(16) NOT NULL
|
||||
event_date : DATE NOT NULL
|
||||
date_precision : VARCHAR(16) NOT NULL DEFAULT 'YEAR'
|
||||
event_date_end : DATE
|
||||
description : TEXT
|
||||
created_by : UUID NOT NULL
|
||||
created_at : TIMESTAMP
|
||||
updated_by : UUID NOT NULL
|
||||
updated_at : TIMESTAMP
|
||||
version : BIGINT
|
||||
==
|
||||
CHECK ((date_precision = 'RANGE') = (event_date_end IS NOT NULL))
|
||||
CHECK (date_precision <> 'UNKNOWN')
|
||||
}
|
||||
|
||||
entity timeline_event_persons {
|
||||
timeline_event_id : UUID <<FK>>
|
||||
person_id : UUID <<FK>>
|
||||
}
|
||||
|
||||
entity timeline_event_documents {
|
||||
timeline_event_id : UUID <<FK>>
|
||||
document_id : UUID <<FK>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +479,13 @@ 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)
|
||||
|
||||
' Timeline relationships
|
||||
timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
|
||||
timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
|
||||
timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE)
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
' ⚠ This is a versioned snapshot. Update when the schema changes significantly.
|
||||
' Note: V69 adds columns only (persons.source_ref, tag.source_ref, document
|
||||
' precision/attribution fields); no new FK relationships, so this diagram is unchanged.
|
||||
' Note: V76 swaps persons.birth_year/death_year for birth_date/death_date +
|
||||
' precision columns; columns only, no new FK relationships, diagram unchanged.
|
||||
' Note: V77 adds the timeline_events table + two join tables (Timeline package below).
|
||||
|
||||
hide circle
|
||||
skinparam linetype ortho
|
||||
@@ -66,7 +69,14 @@ package "Supporting" {
|
||||
entity audit_log
|
||||
entity geschichten
|
||||
entity geschichten_persons
|
||||
entity geschichten_documents
|
||||
entity journey_items
|
||||
}
|
||||
|
||||
' ── Timeline (Zeitstrahl) ──
|
||||
package "Timeline" {
|
||||
entity timeline_events
|
||||
entity timeline_event_persons
|
||||
entity timeline_event_documents
|
||||
}
|
||||
|
||||
' Auth relationships
|
||||
@@ -129,7 +139,16 @@ audit_log }o--o| documents : document_id
|
||||
geschichten }o--o| app_users : author_id
|
||||
geschichten_persons }o--|| geschichten : geschichte_id
|
||||
geschichten_persons }o--|| persons : person_id
|
||||
geschichten_documents }o--|| geschichten : geschichte_id
|
||||
geschichten_documents }o--|| documents : document_id
|
||||
journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE)
|
||||
journey_items }o--o| documents : document_id (ON DELETE SET NULL)
|
||||
note right of journey_items : partial UNIQUE (geschichte_id, document_id)\nWHERE document_id IS NOT NULL (V74)
|
||||
note right of geschichten : CHECK length(body) <= 4000\nfor type = JOURNEY (V75)
|
||||
|
||||
' Timeline relationships (V77)
|
||||
timeline_event_persons }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
|
||||
timeline_event_persons }o--|| persons : person_id (ON DELETE CASCADE)
|
||||
timeline_event_documents }o--|| timeline_events : timeline_event_id (ON DELETE CASCADE)
|
||||
timeline_event_documents }o--|| documents : document_id (ON DELETE CASCADE)
|
||||
note right of timeline_events : CHECK event_date_end non-null IFF RANGE\nCHECK date_precision <> 'UNKNOWN' (V77)
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -421,16 +421,16 @@
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||
<tr><td>Page title</td><td>font-family:var(--font-display);font-size:24px;color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
|
||||
<tr><td>Editorial list card</td><td>bg-white shadow-sm border border-brand-sand rounded-sm</td><td>wraps alle Zeilen</td></tr>
|
||||
<tr><td>Editorial list card</td><td><s>bg-white shadow-sm border border-brand-sand rounded-sm</s> <em>(implementiert: bg-surface border-line — semantische Tokens für Dark Mode)</em></td><td>wraps alle Zeilen</td></tr>
|
||||
<tr class="grp"><td colspan="3">Listenzeile</td></tr>
|
||||
<tr><td>List row</td><td>flex gap-0 border-b border-brand-sand last:border-0 hover:bg-surface</td><td>min-h-[44px] auf Mobile</td></tr>
|
||||
<tr><td>Meta column</td><td>w-[88px] shrink-0 flex flex-col gap-1 p-3 border-r border-brand-sand</td><td>feste Breite</td></tr>
|
||||
<tr><td>Meta column</td><td>w-40 shrink-0 flex flex-col gap-1 p-3 border-r border-line-2</td><td>feste Breite — breit genug für text-sm Namen ohne Umbruch</td></tr>
|
||||
<tr><td>Author avatar</td><td>w-7 h-7 rounded-full text-[9px] font-bold text-white flex items-center justify-center</td><td>personAvatarColor(userId)</td></tr>
|
||||
<tr><td>Author name</td><td>font-sans text-xs font-semibold text-ink</td><td></td></tr>
|
||||
<tr><td>Date</td><td>font-sans text-xs text-ink-3</td><td>formatDate(publishedAt)</td></tr>
|
||||
<tr><td>Author name</td><td>font-sans text-sm font-semibold text-ink</td><td></td></tr>
|
||||
<tr><td>Date</td><td>font-sans text-sm text-ink-3</td><td>formatDate(publishedAt)</td></tr>
|
||||
<tr><td>Person chip</td><td>inline-flex items-center gap-1 rounded-full bg-surface border border-line px-2 py-0.5 text-[10px] font-medium text-ink</td><td>links zu /persons/[id]; optional</td></tr>
|
||||
<tr><td>Story title</td><td>font-serif text-[15px] text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr>
|
||||
<tr><td>Excerpt</td><td>font-sans text-xs text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr>
|
||||
<tr><td>Story title</td><td>font-serif text-lg text-ink leading-snug mb-1 hover:text-primary</td><td>link zu /geschichten/[id]</td></tr>
|
||||
<tr><td>Excerpt</td><td>font-sans text-sm text-ink-3 line-clamp-2</td><td>max. 150 Zeichen aus body</td></tr>
|
||||
<tr class="grp"><td colspan="3">Filter</td></tr>
|
||||
<tr><td>Filter pill (inaktiv)</td><td>rounded-full border border-line px-3 py-1 text-xs font-semibold text-ink-2 hover:bg-muted</td><td>aria-pressed="false"</td></tr>
|
||||
<tr><td>Filter pill (aktiv)</td><td>rounded-full bg-primary text-primary-fg px-3 py-1 text-xs font-semibold</td><td>aria-pressed="true"</td></tr>
|
||||
@@ -640,7 +640,8 @@
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Artikel-Container</td></tr>
|
||||
<tr><td>Article container</td><td>max-w-3xl mx-auto px-4 py-10</td><td>zentriert, volle Breite auf Mobile</td></tr>
|
||||
<tr><td>Article container</td><td>max-w-7xl mx-auto px-4 py-8; innere Lesespalte: max-w-3xl mx-auto</td><td>Seite so breit wie Dokumente/Personen; Textspalte bleibt lesbar zentriert</td></tr>
|
||||
<tr><td>Article sheet</td><td>rounded-sm border border-line bg-sheet shadow-sm px-5 py-6 sm:px-10 sm:py-10</td><td>Lesebogen-Panel zwischen Canvas und weißen Karten (Token --color-sheet); BackButton bleibt außerhalb</td></tr>
|
||||
<tr><td>Story title</td><td>font-family:var(--font-display);font-size:clamp(22px,4vw,32px);color:var(--navy)</td><td>Fraunces, nicht fett</td></tr>
|
||||
<tr><td>Back button</td><td><BackButton /> aus $lib/components/BackButton.svelte</td><td>history.back(); nicht <a href></td></tr>
|
||||
<tr class="grp"><td colspan="3">Metazeile</td></tr>
|
||||
@@ -657,7 +658,7 @@
|
||||
<tr><td>Doc reference card</td><td>flex gap-3 items-start bg-white border border-brand-sand rounded-sm p-3 hover:shadow-sm</td><td>links zu /documents/[id]</td></tr>
|
||||
<tr><td>Doc icon</td><td>w-9 h-9 bg-surface rounded flex items-center justify-center shrink-0</td><td>Dateisymbol SVG</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
||||
<tr><td>… Menü (Mobile)</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr>
|
||||
<tr><td>… Menü (Mobile)</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: Bearbeiten/Löschen bleiben inline in der Metazeile auf allen Breiten — kein BottomSheet)</em></td><td>BLOG_WRITE-Aktionen auf Mobile</td></tr>
|
||||
<tr><td>Person chips (Mobile)</td><td>flex-wrap, volle Breite</td><td>kein horizontales Scrollen</td></tr>
|
||||
<tr><td>Doc cards (Mobile)</td><td>flex-col gap-2</td><td>stapeln vertikal</td></tr>
|
||||
</tbody>
|
||||
@@ -712,7 +713,7 @@
|
||||
<ul>
|
||||
<li>Die Schaltflächen „+ Neue Geschichte", „Bearbeiten" und „Löschen" werden nur gerendert, wenn <code>currentUser.permissions.includes('BLOG_WRITE')</code> wahr ist.</li>
|
||||
<li>Nicht nur ausblenden — Backend-Endpunkte für Schreib-/Löschoperationen sind ebenfalls durch <code>@RequirePermission(Permission.BLOG_WRITE)</code> geschützt.</li>
|
||||
<li>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</li>
|
||||
<li><s>Auf Mobile werden Bearbeiten/Löschen aus dem Layout entfernt und erscheinen in einem BottomSheet, das über das ··· Menü in der Metazeile geöffnet wird.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets, kein BottomSheet)</em></li>
|
||||
</ul>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
|
||||
@@ -500,7 +500,7 @@
|
||||
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
|
||||
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: bg-orange-50 border-orange-200</td></tr>
|
||||
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: <del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> / <code>--color-interlude-border</code> CSS tokens</td></tr>
|
||||
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
|
||||
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
|
||||
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
|
||||
@@ -508,18 +508,18 @@
|
||||
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
|
||||
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Link</td><td>text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1</td><td>togglet Notiz-Textarea</td></tr>
|
||||
<tr><td>„Notiz hinzufügen" Link</td><td><del>text-xs font-semibold text-blue-600</del> → <code>text-xs text-ink-3 underline hover:text-accent</code></td><td>togglet Notiz-Textarea</td></tr>
|
||||
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
|
||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||
<tr><td>Interlude-Container</td><td>bg-orange-50 border-orange-200 (überschreibt Item-Container)</td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||
<tr><td>Label „Zwischentext"</td><td>text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1</td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||
<tr><td>Zwischentext-Textarea</td><td>w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400</td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||
<tr><td>Interlude-Container</td><td><del>bg-orange-50 border-orange-200</del> → <code>--color-interlude-bg</code> left-accent border via <code>--color-interlude-border</code></td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
|
||||
<tr><td>Label „Zwischentext"</td><td><del>text-orange-700</del> → <code>color: var(--color-interlude-label)</code></td><td>immer sichtbar; nicht editierbar</td></tr>
|
||||
<tr><td>Zwischentext-Textarea</td><td><del>border-orange-200 focus:border-orange-400</del> → <code>border-line focus-visible:ring-focus-ring</code></td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
|
||||
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
|
||||
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
|
||||
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
|
||||
<tr><td>Bibliothek</td><td>@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)</td><td>kein neues Package ohne Absprache</td></tr>
|
||||
<tr><td>Bibliothek</td><td><del>@dnd-kit/core oder svelte-dnd-action</del> → <code>createBlockDragDrop<JourneyItemView></code> aus <code>$lib/document/transcription/useBlockDragDrop.svelte</code></td><td>kein externes Package; pointer-Events + data-drag-handle / data-block-wrapper Kontrakt</td></tr>
|
||||
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
|
||||
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
|
||||
</tbody>
|
||||
@@ -720,7 +720,7 @@
|
||||
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
|
||||
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
|
||||
<tr class="grp"><td colspan="3">Touch & Drag</td></tr>
|
||||
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr>
|
||||
<tr><td>Drag auf Mobile</td><td>Move-Up/Down Buttons statt Drag (44px touch targets)</td><td><del>dnd-kit unterstützt Touch nativ</del> → Pointer-Drag nur Desktop; Keyboard via Pfeil-Buttons</td></tr>
|
||||
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
|
||||
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
|
||||
<tr class="grp"><td colspan="3">Savebar</td></tr>
|
||||
@@ -779,7 +779,7 @@
|
||||
|
||||
<h3>Drag-to-Reorder</h3>
|
||||
<ul>
|
||||
<li>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist. Kein neues Package einführen ohne Absprache.</li>
|
||||
<li><del>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist.</del> → Implementiert mit <code>createBlockDragDrop<JourneyItemView></code> (kein externes Package).</li>
|
||||
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
|
||||
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
|
||||
</ul>
|
||||
|
||||
@@ -629,27 +629,28 @@
|
||||
<tbody>
|
||||
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
|
||||
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
|
||||
<tr><td>Artikel-Container</td><td>max-w-3xl mx-auto px-4 py-8</td><td>gleich wie StoryReader</td></tr>
|
||||
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
|
||||
<tr><td>Artikel-Container</td><td>max-w-7xl mx-auto px-4 py-8; innere Lesespalte: max-w-3xl mx-auto</td><td>gleich wie StoryReader (R-2)</td></tr>
|
||||
<tr><td>Artikel-Sheet</td><td>rounded-sm border border-line bg-sheet shadow-sm px-5 py-6 sm:px-10 sm:py-10</td><td>Lesebogen-Panel zwischen Canvas und weißen Karten (Token --color-sheet), gleich wie Story (R-2); BackButton bleibt außerhalb</td></tr>
|
||||
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-journey-tint text-journey border border-journey-border mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
|
||||
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr>
|
||||
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; <s>auf Mobile im ··· BottomSheet</s> <em>(implementiert: inline in der Metazeile auf allen Breiten)</em></td><td>gleich wie Story</td></tr>
|
||||
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
|
||||
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
|
||||
<tr><td>Intro (body)</td><td>font-serif text-lg text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
|
||||
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
|
||||
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
|
||||
<tr><td>Dokumentkarte</td><td>bg-white border border-line rounded-sm p-3</td><td></td></tr>
|
||||
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
|
||||
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
|
||||
<tr><td>Dokumentkarte</td><td>bg-surface border border-line rounded-sm p-3</td><td></td></tr>
|
||||
<tr><td>Brieftitel</td><td>font-serif text-base text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
|
||||
<tr><td>Briefmeta</td><td>text-sm text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
|
||||
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-sm font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
|
||||
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
|
||||
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr>
|
||||
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-brand-mint bg-muted rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
|
||||
<tr><td>Annotations-Text</td><td>text-base italic text-ink-2 leading-relaxed</td><td></td></tr>
|
||||
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
|
||||
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
|
||||
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
|
||||
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-journey-border bg-journey-tint rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
|
||||
<tr><td>Interlude-Text</td><td>text-base italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mobile</td></tr>
|
||||
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr>
|
||||
<tr><td>··· Menü</td><td><s>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</s> <em>(implementiert: kein BottomSheet — Aktionen inline)</em></td><td>BLOG_WRITE; gleich wie Story</td></tr>
|
||||
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -710,7 +711,7 @@
|
||||
<h3>Berechtigungen</h3>
|
||||
<ul>
|
||||
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
|
||||
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li>
|
||||
<li><s>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</s> <em>(implementiert: Aktionen bleiben inline in der Metazeile — h-11 Touch-Targets)</em></li>
|
||||
</ul>
|
||||
|
||||
<h3>Barrierefreiheit</h3>
|
||||
|
||||
418
docs/specs/zeitstrahl-event-editor-spec.html
Normal file
418
docs/specs/zeitstrahl-event-editor-spec.html
Normal file
@@ -0,0 +1,418 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Zeitstrahl — Ereignis-Editor & Brief-Gruppierung · Quick-Action im Dokument · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
|
||||
|
||||
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
|
||||
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
|
||||
.mh p{font-size:13px;color:#555;max-width:790px;line-height:1.75;margin-top:8px}
|
||||
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
|
||||
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
|
||||
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||
.tg.mint{background:#a1dcd8;color:#012851}
|
||||
.tg.slate{background:#607080;color:#e8edf2}
|
||||
|
||||
.sh{margin:60px 0 22px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
|
||||
.sh h2{font-size:17px;font-weight:900;color:#012851}
|
||||
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:790px;line-height:1.65}
|
||||
|
||||
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
|
||||
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
|
||||
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
|
||||
.callout code{font-family:'Courier New',monospace;font-size:11px;background:#fff;padding:1px 4px;border-radius:2px}
|
||||
.callout strong{font-weight:800;color:#012851}
|
||||
|
||||
/* desktop chrome */
|
||||
.dchrome{background:#F0EFE9;border:1.5px solid #C4C0BA;border-radius:9px;overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,.12)}
|
||||
.dbar{height:22px;background:#E2DFD8;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;gap:4px;padding:0 9px}
|
||||
.ddot{width:7px;height:7px;border-radius:50%;background:#C4BFB8}
|
||||
.durl{flex:1;height:10px;background:#D2CEC8;border-radius:5px;margin:0 8px;max-width:360px}
|
||||
.dnav{height:32px;background:#012851;display:flex;align-items:center;gap:13px;padding:0 16px}
|
||||
.dnav .nlogo{font-family:'Tinos',serif;font-size:10px;color:#fff;font-weight:700}
|
||||
.dnav .nlink{font-size:7.5px;color:rgba(255,255,255,.5);font-weight:700;text-transform:uppercase;letter-spacing:.4px}
|
||||
.dnav .nlink.on{color:#fff;border-bottom:2px solid #a1dcd8;padding-bottom:9px}
|
||||
.dnav .av{margin-left:auto;width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.55)}
|
||||
.dcanvas{background:#f0efe9;padding:20px 26px 26px}
|
||||
|
||||
/* form atoms (mirror real Tailwind look) */
|
||||
.lbl{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:6px}
|
||||
.inp{border:1px solid #e4e2d7;border-radius:4px;background:#fff;padding:8px 11px;font-size:12px;color:#012851}
|
||||
.inp.title{font-family:'Tinos',serif;font-size:22px;font-weight:700;padding:11px 13px}
|
||||
.card{border:1px solid #e4e2d7;border-radius:6px;background:#fff;padding:15px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
.card h3{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:3px}
|
||||
.card .hint{font-size:9.5px;color:#9a9a96;margin-bottom:9px}
|
||||
.seg{display:inline-flex;border:1px solid #012851;border-radius:5px;overflow:hidden}
|
||||
.seg span{font-size:10px;font-weight:700;padding:5px 13px;color:#012851}
|
||||
.seg span.on{background:#012851;color:#fff}
|
||||
.seg span+span{border-left:1px solid #012851}
|
||||
.chips{border:1px solid #e4e2d7;border-radius:5px;background:#fff;padding:7px;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
||||
.chip-sel{display:inline-flex;align-items:center;gap:4px;background:#f5f4ef;border-radius:4px;padding:3px 8px;font-size:10px;color:#012851}
|
||||
.chip-sel .x{color:#012851;opacity:.45;font-size:11px}
|
||||
.chip-in{flex:1;min-width:90px;font-size:10.5px;color:#9a9a96;padding:3px 4px}
|
||||
.btn{display:inline-flex;align-items:center;gap:6px;height:38px;padding:0 16px;border-radius:5px;font-size:12px;font-weight:600}
|
||||
.btn.primary{background:#012851;color:#fff}
|
||||
.btn.ghost{background:#fff;border:1px solid #e4e2d7;color:#012851}
|
||||
.btn.danger{background:#fff;border:1px solid #e7c9c4;color:#c0392b}
|
||||
.tagchip{display:inline-flex;align-items:center;gap:3px;font-size:8px;border-radius:9px;padding:2px 7px;color:#a0522d;background:#f6ece6}
|
||||
.tagchip i{width:6px;height:6px;border-radius:2px;background:#a0522d;display:inline-block}
|
||||
|
||||
/* annotation callouts on mockups */
|
||||
.anno{display:flex;gap:9px;align-items:flex-start;font-size:11.5px;color:#4a4a46;line-height:1.55;margin-bottom:7px}
|
||||
.anno .n{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;margin-top:1px}
|
||||
.anno b{color:#012851}
|
||||
.anno code{font-size:10px;background:#F0EFE9;padding:1px 4px;border-radius:2px}
|
||||
.annogrid{display:grid;grid-template-columns:1fr 1fr;gap:6px 26px;margin-top:14px}
|
||||
|
||||
/* dropdown */
|
||||
.dd{border:1px solid #e4e2d7;border-radius:6px;background:#fff;box-shadow:0 6px 18px rgba(0,0,0,.12);overflow:hidden;margin-top:3px}
|
||||
.dd .opt{padding:7px 11px;font-size:11px;color:#012851;border-bottom:1px solid #f3f1ea;cursor:pointer}
|
||||
.dd .opt:hover,.dd .opt.hl{background:#f5f4ef}
|
||||
.dd .opt:last-child{border-bottom:none}
|
||||
.dd .opt .d{font-size:8.5px;color:#9a9a96}
|
||||
|
||||
/* states grid */
|
||||
.states{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}
|
||||
.state{border:1px solid #E0DDD6;border-radius:8px;background:#fff;overflow:hidden}
|
||||
.state .sh2{background:#F4F2EC;border-bottom:1px solid #E0DDD6;padding:7px 12px;font-size:8.5px;font-weight:800;letter-spacing:.8px;text-transform:uppercase;color:#012851;display:flex;justify-content:space-between}
|
||||
.state .sb{padding:13px}
|
||||
.cap{font-size:11px;color:#888;font-style:italic;line-height:1.55;margin-top:8px}
|
||||
|
||||
/* impl-ref */
|
||||
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
|
||||
.impl-ref table{width:100%;border-collapse:collapse}
|
||||
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
|
||||
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
|
||||
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
|
||||
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:185px}
|
||||
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
|
||||
hr{border:none;border-top:2px dashed #C8C4BE;margin:52px 0}
|
||||
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- ══ MASTHEAD ══ -->
|
||||
<div class="mh">
|
||||
<h1>Ereignis-Editor & Brief-Gruppierung · Quick-Action im Dokument</h1>
|
||||
<p>Wie kuratierte Zeitstrahl-Ereignisse entstehen und wie Briefe gruppiert werden — von zwei Seiten in ein Datenmodell (<code style="font-family:monospace;font-size:12px">TimelineEvent.documents</code>): der <strong>Ereignis-Editor</strong> unter <code style="font-family:monospace;font-size:12px">/zeitstrahl/events/[id]/edit</code> (Kurator baut, verlinkt viele Briefe) und die <strong>Quick-Action im Dokument-Detail</strong> (beim Lesen schnell zuordnen). Beide bauen auf bereits ausgelieferten Komponenten auf.</p>
|
||||
<div class="tag-row">
|
||||
<span class="tg">Milestone #14 · Zeitstrahl</span>
|
||||
<span class="tg mint">Reuse: GeschichteEditor · DocumentMultiSelect · PersonMultiSelect</span>
|
||||
<span class="tg slate">WRITE_ALL</span>
|
||||
</div>
|
||||
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · gegründet auf Code: GeschichteEditor.svelte · DocumentMetadataDrawer.svelte · DocumentMultiSelect.svelte</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 1 · TWO ENTRY POINTS ══ -->
|
||||
<div class="sh">
|
||||
<h2>1 · Zwei Einstiegspunkte, ein Datenmodell</h2>
|
||||
<p>Manuelle Gruppierung = ein <code>TimelineEvent</code> mit verknüpften Dokumenten. Kuratoren arbeiten in beide Richtungen — wir bauen beide, statt eine zu erzwingen.</p>
|
||||
</div>
|
||||
<div class="states" style="grid-template-columns:1fr 1fr">
|
||||
<div class="callout navy" style="margin:0">
|
||||
<strong>A · Ereignis-zuerst</strong> — der Kurator baut den Zeitstrahl. <code>/zeitstrahl/events/new · [id]/edit</code> mit Dokument-Mehrfach-Picker = <b>Bulk-Linking</b> vieler Briefe auf einmal. Spiegelt 1:1 den <code>GeschichteEditor</code> (gleiche zwei-Spalten-Form, Sidebar-Picker, Sticky-Save-Bar).
|
||||
</div>
|
||||
<div class="callout mint" style="margin:0">
|
||||
<strong>B · Dokument-zuerst</strong> — beim Lesen eines Briefs. Quick-Action im Dokument-Detail: bestehendes Ereignis wählen <i>oder</i> neu anlegen, verlinkt diesen einen Brief. Spiegelt die bestehende <b>Geschichten-Spalte</b> im Details-Drawer (<code>DocumentMetadataDrawer.svelte</code>).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 2 · EVENT EDITOR ══ -->
|
||||
<div class="sh">
|
||||
<h2>2 · Ereignis-Editor — <code style="font-size:14px">/zeitstrahl/events/[id]/edit</code></h2>
|
||||
<p>Form-Actions-Muster, gegated mit <code>WRITE_ALL</code>. Layout & Verhalten 1:1 vom <code>GeschichteEditor</code> übernommen: Hauptspalte + Sidebar (<code>lg:grid-cols-[2fr_1fr]</code>), Sticky-Save-Bar, <code>beforeNavigate</code>-Warnung bei ungespeicherten Änderungen.</p>
|
||||
</div>
|
||||
|
||||
<div class="dchrome" style="margin-bottom:16px">
|
||||
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
|
||||
<div class="dnav"><span class="nlogo">Familienarchiv</span><span class="nlink">Dokumente</span><span class="nlink">Personen</span><span class="nlink on">Zeitstrahl</span><span class="nlink">Stammbaum</span><span class="av">KR</span></div>
|
||||
<div class="dcanvas">
|
||||
<div style="font-size:8px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#8a8a86;margin-bottom:10px">‹ Zurück zum Zeitstrahl</div>
|
||||
<div style="font-family:'Tinos',serif;font-size:18px;font-weight:700;color:#012851;margin-bottom:16px">Ereignis bearbeiten</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr;gap:22px">
|
||||
<!-- MAIN COLUMN -->
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<!-- ① title -->
|
||||
<div>
|
||||
<div class="inp title" style="width:100%">Briefe von der Front</div>
|
||||
</div>
|
||||
<!-- ② type + ③ date/precision -->
|
||||
<div style="display:flex;gap:22px;flex-wrap:wrap">
|
||||
<div>
|
||||
<div class="lbl">② Typ</div>
|
||||
<div class="seg"><span class="on">Persönlich</span><span>Historisch</span></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="lbl">③ Datum · Präzision</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<div class="inp" style="width:120px">1915</div>
|
||||
<div class="inp" style="display:flex;align-items:center;gap:18px;color:#6b7280">Jahr <span style="font-size:8px">▾</span></div>
|
||||
</div>
|
||||
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Bei „Zeitspanne" erscheint ein zweites End-Datum-Feld. Bei „ca." / „Saison" passt sich nur das Label an.</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ④ description -->
|
||||
<div>
|
||||
<div class="lbl">④ Beschreibung <span style="color:#bbb;font-weight:600">· optional</span></div>
|
||||
<div class="inp" style="width:100%;min-height:96px;color:#4a4a46;font-family:'Tinos',serif;line-height:1.6">Karls Feldpost von der Westfront, 1915 — wöchentliche Briefe an Elfriede und den neugeborenen Hans. Eine zusammenhängende Korrespondenz, die hier als Cluster gebündelt wird …</div>
|
||||
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Schlichtes Textfeld (kein Rich-Text wie Geschichten) — Ereignisse sind kurze Notizen, keine Langform.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside style="display:flex;flex-direction:column;gap:16px">
|
||||
<!-- ⑤ linked letters = grouping -->
|
||||
<div class="card" style="border-color:#a1dcd8;box-shadow:0 2px 10px rgba(161,220,216,.3)">
|
||||
<h3 style="color:#012851">⑤ Verknüpfte Briefe · 24</h3>
|
||||
<div class="hint">Diese Briefe bilden den Cluster. <code style="font-size:9px">DocumentMultiSelect</code></div>
|
||||
<div class="chips">
|
||||
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
|
||||
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
|
||||
<span class="chip-sel">✉ Brief an Elfriede · Sep 1915 <span class="x">×</span></span>
|
||||
<span class="chip-in">Brief suchen …</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ⑥ persons -->
|
||||
<div class="card">
|
||||
<h3>⑥ Beteiligte Personen</h3>
|
||||
<div class="hint">Treibt die Lebensweg-Ansicht & Filter. <code style="font-size:9px">PersonMultiSelect</code></div>
|
||||
<div class="chips">
|
||||
<span class="chip-sel">Karl Raddatz <span class="x">×</span></span>
|
||||
<span class="chip-sel">Elfriede Raddatz <span class="x">×</span></span>
|
||||
<span class="chip-sel">Hans Raddatz <span class="x">×</span></span>
|
||||
<span class="chip-in">Person suchen …</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- ⑦ sticky save bar -->
|
||||
<div style="margin:18px -26px -26px;border-top:1px solid #e4e2d7;background:#fff;box-shadow:0 -2px 8px rgba(0,0,0,.05);padding:13px 26px;display:flex;align-items:center;justify-content:space-between">
|
||||
<span style="font-size:10px;color:#9a9a96">Änderungen werden erst beim Speichern übernommen.</span>
|
||||
<div style="display:flex;gap:8px">
|
||||
<span class="btn danger">Löschen</span>
|
||||
<span class="btn ghost">Abbrechen</span>
|
||||
<span class="btn primary">Speichern</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="annogrid">
|
||||
<div class="anno"><span class="n">①</span><span><b>Titel</b> — großes Serifen-Feld, wie der Geschichten-Titel. Pflichtfeld (Validierung bei Blur).</span></div>
|
||||
<div class="anno"><span class="n">②</span><span><b>Typ</b> — <code>PERSONAL</code> / <code>HISTORICAL</code> Segmented-Control. Steuert Rendering (Mint-Pille vs. Welt-Band).</span></div>
|
||||
<div class="anno"><span class="n">③</span><span><b>Datum + Präzision</b> — geteilte <code>DatePrecisionInput</code> (gleiche Logik wie Dokument-Datum, <code>metaDatePrecision</code>). „Zeitspanne" blendet End-Datum ein.</span></div>
|
||||
<div class="anno"><span class="n">④</span><span><b>Beschreibung</b> — optionales Textfeld (<code>TEXT</code>), bewusst schlicht.</span></div>
|
||||
<div class="anno"><span class="n">⑤</span><span><b>Verknüpfte Briefe</b> — <b>hier wird gruppiert.</b> Wiederverwendung von <code>DocumentMultiSelect</code> (Typeahead, Chips, Hidden-Inputs).</span></div>
|
||||
<div class="anno"><span class="n">⑥</span><span><b>Beteiligte Personen</b> — <code>PersonMultiSelect</code>. Bestimmt, in welchem „Lebensweg" das Ereignis auftaucht.</span></div>
|
||||
<div class="anno"><span class="n">⑦</span><span><b>Sticky-Save-Bar</b> — Speichern primär, Abbrechen sekundär, Löschen nur im Edit-Modus (mit Bestätigung).</span></div>
|
||||
<div class="anno"><span class="n">+</span><span><b>/new</b> — leeres Formular. Mit <code>?documentId=…</code> ist Feld ⑤ vorbefüllt (aus der Quick-Action, §4-D).</span></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 3 · GROUPING / DOCUMENT PICKER ══ -->
|
||||
<div class="sh">
|
||||
<h2>3 · Brief-Gruppierung im Editor — der Dokument-Picker</h2>
|
||||
<p>Feld ⑤ ist der unveränderte <code>DocumentMultiSelect</code>: Tippen sucht über <code>/api/documents/search?q=…</code> (debounced 300 ms), Treffer mit ehrlichem Datums-Label, bereits gewählte werden gefiltert. Jeder Klick fügt einen Brief zum Cluster.</p>
|
||||
</div>
|
||||
|
||||
<div class="states">
|
||||
<div class="state">
|
||||
<div class="sh2"><span>Suche aktiv — Dropdown</span><span style="color:#9a9a96">DocumentMultiSelect</span></div>
|
||||
<div class="sb">
|
||||
<div class="chips" style="margin-bottom:0">
|
||||
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
|
||||
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
|
||||
<span class="chip-in" style="color:#012851">Verdun▏</span>
|
||||
</div>
|
||||
<div class="dd">
|
||||
<div class="opt hl">Feldpost aus Verdun <span class="d">· Brief · Juli 1915</span></div>
|
||||
<div class="opt">Brief aus dem Verdun-Lazarett <span class="d">· Brief · August 1916</span></div>
|
||||
<div class="opt">Rückkehr aus Verdun <span class="d">· Brief · ca. 1917</span></div>
|
||||
</div>
|
||||
<div class="cap">Label = <code style="font-size:10px">title · formatDocumentDate(precision)</code>. Bereits verknüpfte Briefe erscheinen nicht in den Treffern (Dedup).</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="state">
|
||||
<div class="sh2"><span>Inline „+ Ereignis" am Jahres-Band</span><span style="color:#9a9a96">Zeitstrahl</span></div>
|
||||
<div class="sb">
|
||||
<div style="position:relative;padding-left:20px">
|
||||
<div style="position:absolute;left:6px;top:2px;bottom:2px;width:2px;background:linear-gradient(#a1dcd8,#012851)"></div>
|
||||
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;margin-bottom:8px">1915</div>
|
||||
<div style="background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:7px 9px;margin-bottom:8px"><div style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</div><div style="font-size:8px;color:#9a9a96">Monats-Dichte ▾</div></div>
|
||||
<button style="display:inline-flex;align-items:center;gap:5px;border:1px dashed #a1dcd8;background:#fff;border-radius:6px;padding:5px 11px;font-size:10px;font-weight:600;color:#012851">+ Ereignis aus diesem Jahr anlegen</button>
|
||||
</div>
|
||||
<div class="cap">Kuratoren können auch direkt im Zeitstrahl ein Ereignis anlegen — öffnet denselben Editor, Jahr & Briefe des Bandes vorbefüllt.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- ══ 4 · QUICK ACTION IN DOCUMENT DETAIL ══ -->
|
||||
<div class="sh">
|
||||
<h2>4 · Quick-Action im Dokument-Detail — wo sie lebt</h2>
|
||||
<p>Die Dokument-Detailseite ist ein <b>vollflächiger Viewer ohne Sidebar</b> (<code>fixed inset</code>). Aktions-Flächen gibt es nur zwei: die <code>DocumentTopBar</code> und den aufklappbaren <b>Details-Drawer</b>. Die Quick-Action lebt an beiden — primär als <b>„Zeitstrahl"-Spalte im Drawer</b> (spiegelt die Geschichten-Spalte), plus ein <b>Top-Bar-Button</b> für den Ein-Klick-Weg.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout navy"><strong>Warum der Details-Drawer der richtige Ort ist:</strong> Er zeigt heute schon, <i>wozu ein Brief gehört</i> — Personen, Schlagwörter und <b>Geschichten</b> (mit „Zuordnen"-Aktion, gegated über <code>canBlogWrite</code>, <code>DocumentMetadataDrawer.svelte:210</code>). Zeitstrahl-Ereignisse sind strukturell identisch („dieser Brief gehört zu diesen Ereignissen") und bekommen daher eine gleichwertige vierte/fünfte Spalte. Konsistent & auffindbar dort, wo Nutzer ohnehin „Zugehörigkeit" suchen.</div>
|
||||
|
||||
<div class="dchrome" style="margin-bottom:16px">
|
||||
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
|
||||
<!-- document topbar -->
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;box-shadow:0 1px 3px rgba(0,0,0,.05);display:flex;align-items:center;height:54px">
|
||||
<div style="width:3px;height:100%;background:#012851"></div>
|
||||
<div style="width:34px;display:flex;justify-content:center;color:#6b7280;font-size:14px">‹</div>
|
||||
<div style="width:1px;height:22px;background:#e4e2d7;margin:0 6px"></div>
|
||||
<div style="flex:0 1 auto;min-width:0;padding-right:10px">
|
||||
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Brief über die Lage an der Westfront</div>
|
||||
<div style="font-size:8.5px;color:#6b7280">März 1915</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:3px;margin-left:6px"><span style="width:20px;height:20px;border-radius:50%;background:#012851;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-size:9px;color:#9a9a96;align-self:center">→</span><span style="width:20px;height:20px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">ER</span></div>
|
||||
<div style="margin-left:auto;display:flex;align-items:center;gap:7px;padding-right:14px">
|
||||
<!-- Details toggle (active) -->
|
||||
<span style="display:inline-flex;align-items:center;gap:5px;background:#012851;color:#fff;border-radius:5px;font-size:10px;font-weight:600;padding:6px 11px">Details ▴</span>
|
||||
<div style="width:1px;height:22px;background:#e4e2d7"></div>
|
||||
<!-- action buttons -->
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;font-weight:600;color:#012851;padding:6px 11px">✎ Transkribieren</span>
|
||||
<!-- NEW: Zeitstrahl quick button -->
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;background:#a1dcd8;color:#012851;border-radius:5px;font-size:10px;font-weight:700;padding:6px 11px">⊕ Zeitstrahl</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;color:#012851;padding:6px 9px">⤓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- metadata drawer (opened) -->
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:20px 24px">
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:24px">
|
||||
<!-- Details -->
|
||||
<div>
|
||||
<div class="lbl">Details</div>
|
||||
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Datum</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">März 1915</div>
|
||||
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Ort</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">Westfront</div>
|
||||
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Status</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851">Transkribiert</div>
|
||||
</div>
|
||||
<!-- Personen -->
|
||||
<div>
|
||||
<div class="lbl">Personen</div>
|
||||
<div style="display:flex;align-items:center;gap:7px;margin-bottom:6px"><span style="width:26px;height:26px;border-radius:50%;background:#012851;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Karl Raddatz</span></div>
|
||||
<div style="display:flex;align-items:center;gap:7px"><span style="width:26px;height:26px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">ER</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Elfriede Raddatz</span></div>
|
||||
</div>
|
||||
<!-- Schlagwörter -->
|
||||
<div>
|
||||
<div class="lbl">Schlagwörter</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:5px"><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Krieg</span><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Briefe von der Front</span></div>
|
||||
</div>
|
||||
<!-- NEW: Zeitstrahl column -->
|
||||
<div style="border-left:2px solid #eef6f5;padding-left:18px;margin-left:-6px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px">
|
||||
<span class="lbl" style="margin:0;color:#012851">Zeitstrahl</span>
|
||||
<span style="font-size:9px;font-weight:600;color:#6b7280">+ Zuordnen</span>
|
||||
</div>
|
||||
<!-- linked event -->
|
||||
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px;margin-bottom:8px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="color:#9a9a96;font-size:11px">×</span></div>
|
||||
<div style="font-size:8px;color:#9a9a96;margin-top:1px">1915 · 24 Briefe · persönlich</div>
|
||||
<span class="tagchip" style="margin-top:5px"><i></i>Krieg</span>
|
||||
</div>
|
||||
<!-- quick add row -->
|
||||
<div style="display:flex;gap:6px">
|
||||
<span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:9px;color:#9a9a96;padding:5px 8px">Ereignis suchen …</span>
|
||||
<span style="background:#012851;color:#fff;border-radius:4px;font-size:9px;font-weight:600;padding:5px 8px;white-space:nowrap">+ Neu</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:120px;background:repeating-linear-gradient(45deg,#ececec,#ececec 8px,#e4e4e4 8px,#e4e4e4 16px);display:flex;align-items:center;justify-content:center;color:#aaa;font-size:10px">↓ PDF-Viewer (Brief-Scan) …</div>
|
||||
</div>
|
||||
|
||||
<div class="annogrid">
|
||||
<div class="anno"><span class="n">A</span><span><b>Top-Bar-Button „⊕ Zeitstrahl"</b> — Mint-Akzent im Aktions-Cluster (<code>DocumentTopBarActions</code>). Öffnet ein kleines Popover zum Ein-Klick-Zuordnen, ohne den Drawer zu öffnen. Im Mobile-Menü als Eintrag.</span></div>
|
||||
<div class="anno"><span class="n">B</span><span><b>„Zeitstrahl"-Spalte im Details-Drawer</b> — neue Spalte neben Geschichten. Zeigt verknüpfte Ereignisse (Titel · Datum · Tag-Chip), Unlink über <code>×</code>, plus Quick-Add-Zeile. Nur sichtbar/aktiv bei <code>canWrite</code>.</span></div>
|
||||
<div class="anno"><span class="n">C</span><span><b>Quick-Add-Zeile</b> — Typeahead „Ereignis suchen …" (sofortiges Verlinken, keine Navigation) + <b>„+ Neu"</b>.</span></div>
|
||||
<div class="anno"><span class="n">D</span><span><b>„+ Neu"</b> → <code>/zeitstrahl/events/new?documentId={id}</code> — öffnet den Editor (§2) mit diesem Brief in Feld ⑤ vorbefüllt. Spiegelt <code>/geschichten/new?documentId=</code>.</span></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 5 · QUICK-ADD STATES ══ -->
|
||||
<div class="sh"><h2>5 · Quick-Action — Zustände</h2><p>Der Typeahead in der Zeitstrahl-Spalte (oder im Top-Bar-Popover). Gleiches Muster wie <code>DocumentMultiSelect</code>, nur sucht es Ereignisse statt Dokumente.</p></div>
|
||||
|
||||
<div class="states" style="grid-template-columns:repeat(2,1fr)">
|
||||
<div class="state">
|
||||
<div class="sh2"><span>A · Nicht zugeordnet</span></div>
|
||||
<div class="sb">
|
||||
<div style="font-size:10px;color:#9a9a96;font-style:italic;margin-bottom:9px">Noch keinem Ereignis zugeordnet.</div>
|
||||
<div style="display:flex;gap:6px"><span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:10px;color:#9a9a96;padding:6px 9px">Ereignis suchen …</span><span class="btn primary" style="height:30px;font-size:10px;padding:0 11px">+ Neu</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state">
|
||||
<div class="sh2"><span>B · Suche — Treffer</span></div>
|
||||
<div class="sb">
|
||||
<div style="border:1px solid #012851;border-radius:4px;font-size:10px;color:#012851;padding:6px 9px;margin-bottom:0">Front▏</div>
|
||||
<div class="dd">
|
||||
<div class="opt hl">Briefe von der Front <span class="d">· 1915 · 24 Briefe</span></div>
|
||||
<div class="opt">Kriegsausbruch <span class="d">· 1914 · 6 Briefe</span></div>
|
||||
<div class="opt" style="color:#012851;font-weight:600">+ „Front" als neues Ereignis anlegen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state">
|
||||
<div class="sh2"><span>C · Zugeordnet</span></div>
|
||||
<div class="sb">
|
||||
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px"><div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#2e7d57">✓ verknüpft <span style="color:#9a9a96">×</span></span></div><span class="tagchip" style="margin-top:5px"><i></i>Krieg</span></div>
|
||||
<div class="cap">Sofortiges Verlinken (POST). Toast „Zum Ereignis hinzugefügt", <code style="font-size:10px">aria-live</code>. Unlink über <code>×</code> (DELETE).</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state">
|
||||
<div class="sh2"><span>D · Mehrfach zugeordnet</span></div>
|
||||
<div class="sb">
|
||||
<div style="display:flex;flex-direction:column;gap:5px">
|
||||
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#9a9a96">×</span></div>
|
||||
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Weihnachten 1915</span><span style="font-size:9px;color:#9a9a96">×</span></div>
|
||||
</div>
|
||||
<div class="cap">Ein Brief darf zu mehreren Ereignissen gehören (ManyToMany) — alle werden gelistet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 6 · TOKENS ══ -->
|
||||
<div class="sh"><h2>6 · Wiederverwendete Bausteine & Tokens</h2></div>
|
||||
<div class="callout mint"><strong>Maximal wiederverwenden:</strong> <code>DocumentMultiSelect</code> (Brief-Gruppierung, unverändert) · <code>PersonMultiSelect</code> (Beteiligte) · <code>GeschichteEditor</code>-Layout (zwei Spalten, Sticky-Save, <code>beforeNavigate</code>) · <code>DocumentMetadataDrawer</code>-Spaltenmuster (Quick-Action) · <code>useUnsavedWarning</code> · <code>formatDocumentDate</code> / <code>DatePrecision</code>. Brand-Tokens wie im Zeitstrahl-Spec: Navy <code>#012851</code>, Mint <code>#a1dcd8</code>, Linie <code>#e4e2d7</code>, ink-3 <code>#6b7280</code>, danger <code>#c0392b</code>; Serifen-Titel (Tinos), Sans-Chrome (Montserrat).</div>
|
||||
|
||||
|
||||
<!-- ══ 7 · IMPL-REF ══ -->
|
||||
<div class="sh"><h2>7 · Implementierungs-Referenz & Barrierefreiheit</h2></div>
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<tr><th>Baustein</th><th>Datei / Endpoint</th><th>Verantwortung</th></tr>
|
||||
<tr><td>Editor-Route (neu)</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td><code>+page.server.ts</code> (Form-Actions, <code>WRITE_ALL</code>) + <code>+page.svelte</code>; <code>?documentId=</code> vorbefüllt Feld ⑤</td></tr>
|
||||
<tr><td>Editor-Komponente (neu)</td><td><code>TimelineEventEditor.svelte</code></td><td>Spiegelt <code>GeschichteEditor</code>: Titel, Typ, Datum+Präzision, Beschreibung; Sidebar-Picker; Sticky-Save; <code>beforeNavigate</code></td></tr>
|
||||
<tr><td>Brief-Gruppierung (reuse)</td><td><code>DocumentMultiSelect.svelte</code></td><td>Unverändert — Typeahead <code>/api/documents/search</code>, Chips, Hidden-Inputs <code>documentIds</code></td></tr>
|
||||
<tr><td>Personen (reuse)</td><td><code>PersonMultiSelect.svelte</code></td><td>Unverändert — Beteiligte Personen</td></tr>
|
||||
<tr><td>Datum + Präzision</td><td><code>DatePrecisionInput</code> (geteilt)</td><td>Wie Dokument-Datum (<code>metaDatePrecision</code>); „Zeitspanne" → End-Datum; <code>formatDocumentDate</code> fürs Label</td></tr>
|
||||
<tr><td>Quick-Action-Spalte (neu)</td><td><code>DocumentTimelineColumn.svelte</code></td><td>Im <code>DocumentMetadataDrawer</code> neben Geschichten; verknüpfte Ereignisse + Quick-Add; nur bei <code>canWrite</code></td></tr>
|
||||
<tr><td>Quick-Add-Picker (neu)</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Ereignis-Typeahead; sofort verlinken oder <code>?documentId=</code> zum Editor; auch im Top-Bar-Popover</td></tr>
|
||||
<tr><td>Top-Bar-Button (neu)</td><td><code>DocumentTopBarActions</code> · <code>DocumentMobileMenu</code></td><td>„⊕ Zeitstrahl"-Button (canWrite); öffnet Quick-Add-Popover</td></tr>
|
||||
<tr><td>Backend — CRUD</td><td><code>POST · PUT · DELETE /api/timeline/events</code></td><td><code>TimelineEventController</code>, <code>WRITE_ALL</code>; <code>TimelineEventRequest</code> mit <code>documentIds</code> / <code>personIds</code></td></tr>
|
||||
<tr><td>Backend — Link/Unlink</td><td><code>PUT /api/timeline/events/{id}</code></td><td>Verlinken/Lösen läuft über das Event-Update (<code>documents</code>-Set); kein neuer ErrorCode nötig</td></tr>
|
||||
<tr><td>Barrierefreiheit</td><td>—</td><td>Picker-Dropdowns Tastatur-navigierbar (↑↓↵), <code>aria-live</code> für „verknüpft/gelöst"; 44px-Ziele; sichtbarer Fokus-Ring; Löschen/Unlink mit Bestätigung</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="note">Offene Designentscheidung: Soll der Top-Bar-Button (A) MVP sein oder reicht zunächst die Drawer-Spalte (B)? Empfehlung: <b>B als MVP</b> (spiegelt Geschichten exakt, geringster Aufwand), A als schneller Nachzug.</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
391
docs/specs/zeitstrahl-final-spec.html
Normal file
391
docs/specs/zeitstrahl-final-spec.html
Normal file
@@ -0,0 +1,391 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Globaler Zeitstrahl — Finale Spezifikation (Konzept A) · Milestone #14 · Familienarchiv</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
|
||||
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
|
||||
|
||||
/* Masthead */
|
||||
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
|
||||
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
|
||||
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:8px}
|
||||
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
|
||||
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
|
||||
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
|
||||
.tg.mint{background:#a1dcd8;color:#012851}
|
||||
.tg.slate{background:#607080;color:#e8edf2}
|
||||
|
||||
.sh{margin:60px 0 24px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
|
||||
.sh h2{font-size:17px;font-weight:900;color:#012851}
|
||||
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:780px;line-height:1.65}
|
||||
|
||||
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
|
||||
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
|
||||
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
|
||||
.callout strong{font-weight:800;color:#012851}
|
||||
|
||||
/* legend */
|
||||
.legend{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||||
.lg{background:#fff;border:1px solid #E0DDD6;border-radius:7px;padding:12px 14px;display:flex;gap:11px}
|
||||
.lg .ico{flex-shrink:0;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px}
|
||||
.lg .ttl{font-size:10.5px;font-weight:800;color:#012851;margin-bottom:2px}
|
||||
.lg .body{font-size:9.5px;color:#5a5a56;line-height:1.5}
|
||||
.lg .body code{font-size:8.5px;background:#F0EFE9;padding:1px 3px;border-radius:2px;color:#444}
|
||||
|
||||
/* precision table */
|
||||
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
|
||||
.rules table{width:100%;border-collapse:collapse}
|
||||
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
|
||||
.rules td{font-size:11px;color:#444;padding:7px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.5}
|
||||
.rules tr:last-child td{border-bottom:none}
|
||||
.rules td:first-child{font-weight:700;color:#012851;width:110px}
|
||||
.rules td code{font-size:10px;background:#F0EFE9;padding:1px 5px;border-radius:2px;color:#444}
|
||||
.rules .ex{font-family:'Tinos',serif;font-size:13px;color:#012851}
|
||||
|
||||
/* ── Timeline atoms (Konzept A) ── */
|
||||
.tl-canvas{background:#f0efe9;border:1.5px solid #E0DDD6;border-radius:10px;padding:24px 26px 30px}
|
||||
.dh{font-family:'Tinos',serif;font-size:20px;font-weight:700;color:#012851}
|
||||
.dh-sub{font-size:9.5px;color:#7a7a76;margin-bottom:20px}
|
||||
|
||||
/* desktop centered axis */
|
||||
.axis{position:relative;max-width:780px;margin:0 auto;padding:4px 0}
|
||||
.axis::before{content:"";position:absolute;left:50%;top:0;bottom:0;width:2.5px;background:linear-gradient(#a1dcd8,#012851,#607080);transform:translateX(-50%)}
|
||||
.ybadge{text-align:center;position:relative;margin:4px 0 14px}
|
||||
.ybadge span{background:#012851;color:#fff;font-family:'Tinos',serif;font-size:13px;font-weight:700;padding:2px 15px;border-radius:12px;position:relative;z-index:2}
|
||||
.pill{text-align:center;position:relative;margin-bottom:15px}
|
||||
.pill .inner{display:inline-flex;align-items:center;gap:9px;background:#fff;border:1.5px solid #012851;border-radius:22px;padding:5px 16px 5px 5px;position:relative;z-index:2;box-shadow:0 2px 7px rgba(1,40,81,.12)}
|
||||
.pill.curated .inner{border-color:#a1dcd8;border-width:2px}
|
||||
.pill .gly{width:28px;height:28px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.pill.curated .gly{background:#a1dcd8;color:#012851}
|
||||
.pill .tx{text-align:left}
|
||||
.pill .tx .t{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;display:block;line-height:1.15}
|
||||
.pill .tx .s{font-size:8.5px;color:#8a8a86}
|
||||
.wband{position:relative;margin:0 -26px 15px;padding:7px 26px;background:#EEEBE2;border-top:1px solid #ddd8cc;border-bottom:1px solid #ddd8cc;text-align:center}
|
||||
.wband .t{font-family:'Tinos',serif;font-style:italic;font-size:12px;color:#5a6776}
|
||||
.wband .s{font-size:8.5px;color:#8a8a86;margin-left:8px}
|
||||
.lrow{display:flex;align-items:flex-start;margin-bottom:12px}
|
||||
.lrow .half{flex:1}
|
||||
.lrow .dot{width:13px;height:13px;border-radius:50%;background:#fff;border:2.5px solid #a1dcd8;margin-top:9px;flex-shrink:0;z-index:2;position:relative}
|
||||
.lrow .a{padding-right:26px;text-align:right}
|
||||
.lrow .b{padding-left:26px;text-align:left}
|
||||
.lcard{display:inline-block;text-align:left;background:#fff;border:1px solid #e4e2d7;border-radius:5px;padding:8px 11px;box-shadow:0 1px 3px rgba(0,0,0,.05);max-width:300px}
|
||||
.lcard.ev{border-left:3px solid #a1dcd8}
|
||||
.lcard .t{font-size:11px;font-weight:700;color:#1A1A1A}
|
||||
.lcard.ev .t{font-family:'Tinos',serif}
|
||||
.lcard .m{font-size:8.5px;color:#9a9a96;margin-top:1px}
|
||||
|
||||
.chip{display:inline-flex;align-items:center;gap:3px;font-size:7.5px;border-radius:9px;padding:2px 7px;margin-top:5px}
|
||||
.chip i{width:6px;height:6px;border-radius:2px;display:inline-block;flex-shrink:0}
|
||||
.chip.krieg{color:#a0522d;background:#f6ece6}.chip.krieg i{background:#a0522d}
|
||||
.chip.weih{color:#c17a00;background:#fbf3e3}.chip.weih i{background:#c17a00}
|
||||
.chip.fam{color:#5a8a6a;background:#eaf1ec}.chip.fam i{background:#5a8a6a}
|
||||
|
||||
/* dense aggregate strip */
|
||||
.strip{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;background:#fff;border:1px solid #e4e2d7;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.05);padding:9px 13px}
|
||||
.strip .hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
|
||||
.strip .ct{font-size:10.5px;font-weight:700;color:#012851}
|
||||
.strip .ex{font-size:8px;color:#8a8a86}
|
||||
.spark{display:flex;align-items:flex-end;gap:1.5px;height:30px}
|
||||
.spark div{flex:1;background:#a1dcd8;border-radius:1px;min-height:1px}
|
||||
.strip .axl{display:flex;justify-content:space-between;margin-top:3px}
|
||||
.strip .axl span{font-size:6px;color:#bbb}
|
||||
|
||||
/* compressed gap */
|
||||
.gap{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;display:flex;align-items:center;gap:9px;padding:5px 14px;border:1px dashed #cfccc4;border-radius:18px;background:#f0efe9;color:#9a958c;font-size:9px;font-style:italic}
|
||||
.gap .ln{flex:1;height:1px;background:#ddd8cc}
|
||||
.gap b{color:#7a756c;font-style:normal;font-family:'Tinos',serif;font-size:10px}
|
||||
|
||||
/* undated bucket */
|
||||
.undated{max-width:540px;margin:20px auto 0;background:#fff;border:1px dashed #c4c0ba;border-radius:6px;padding:12px 15px}
|
||||
.undated .h{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#7a756c;margin-bottom:6px}
|
||||
|
||||
/* case tag floating */
|
||||
.casetag{display:inline-block;background:#012851;color:#a1dcd8;font-size:7px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;padding:2px 7px;border-radius:3px;margin-bottom:6px}
|
||||
|
||||
/* narrow (phone / rail) column */
|
||||
.col3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||
.modehd{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#012851;margin-bottom:4px;display:flex;align-items:center;gap:6px}
|
||||
.modehd .seg{background:#012851;color:#fff;font-size:7px;padding:2px 7px;border-radius:4px}
|
||||
.modesub{font-size:9.5px;color:#888;font-style:italic;margin-bottom:9px;line-height:1.45;min-height:42px}
|
||||
.nf{background:#fff;border:1px solid #e4e2d7;border-radius:6px;padding:14px}
|
||||
.nsp{position:relative;padding-left:21px}
|
||||
.nsp::before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:linear-gradient(#a1dcd8,#012851,#607080)}
|
||||
.nyr{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;margin:2px 0 7px;position:relative}
|
||||
.nyr::before{content:"";position:absolute;left:-14px;top:4px;width:8px;height:8px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851}
|
||||
.nnode{position:relative;margin-bottom:9px}
|
||||
.nnode .g{position:absolute;left:-18px;top:0;width:14px;height:14px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851;color:#a1dcd8;font-size:8px;display:flex;align-items:center;justify-content:center}
|
||||
.nnode .lt{font-family:'Tinos',serif;font-size:10.5px;font-weight:700;color:#012851;line-height:1.2}
|
||||
.nnode .lm{font-size:7.5px;color:#8a8a86}
|
||||
.nwb{position:relative;margin:0 0 9px -9px;padding:5px 8px;background:#EEEBE2;border-left:2px solid #607080;border-radius:0 3px 3px 0}
|
||||
.nwb .t{font-family:'Tinos',serif;font-style:italic;font-size:9.5px;color:#5a6776}
|
||||
.nwb .s{font-size:7px;color:#8a8a86}
|
||||
.nletter{position:relative;margin-bottom:7px}
|
||||
.nletter .d{position:absolute;left:-15px;top:3px;width:6px;height:6px;border-radius:50%;background:#fff;border:1.5px solid #a1dcd8}
|
||||
.ncard{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:5px 8px}
|
||||
.ncard .t{font-size:9px;font-weight:700;color:#1A1A1A}
|
||||
.ncard .m{font-size:7px;color:#9a9a96}
|
||||
.nstrip{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:6px 8px;margin-bottom:7px}
|
||||
.nbucket{border:1px solid #e4e2d7;border-radius:4px;padding:5px 8px;margin-bottom:5px;display:flex;align-items:center;justify-content:space-between}
|
||||
|
||||
/* impl-ref */
|
||||
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
|
||||
.impl-ref table{width:100%;border-collapse:collapse}
|
||||
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
|
||||
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
|
||||
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
|
||||
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:165px}
|
||||
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
|
||||
.sw{display:inline-block;width:11px;height:11px;border-radius:2px;vertical-align:-1px;margin-right:5px;border:1px solid rgba(0,0,0,.12)}
|
||||
|
||||
hr{border:none;border-top:2px dashed #C8C4BE;margin:50px 0}
|
||||
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- ══ MASTHEAD ══ -->
|
||||
<div class="mh">
|
||||
<h1>Globaler Zeitstrahl — Finale Spezifikation</h1>
|
||||
<p>Kanonische Spezifikation für <code style="font-family:monospace;font-size:12px">/zeitstrahl</code> auf Basis von <strong>Konzept A „Der Lebensfaden"</strong>: eine durchgehende vertikale Achse, die Personen-Lebensereignisse, kuratierte Ereignisse und Briefe zu einer Erzählung in der Zeit verwebt. Dieselbe Komponente betreibt den globalen Zeitstrahl und den per-Person „Lebensweg". Enthält die vollständige Fall-Abdeckung (leere Jahre, wenige Briefe, hunderte Briefe, undatiert) und die drei Gruppierungs-Modi.</p>
|
||||
<div class="tag-row">
|
||||
<span class="tg">Milestone #14 · Zeitstrahl</span>
|
||||
<span class="tg mint">Konzept A — final</span>
|
||||
<span class="tg slate">Phone-first · honest DatePrecision</span>
|
||||
</div>
|
||||
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · ersetzt die A/B/C-Exploration zeitstrahl-global-concepts.html</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 1 · ANATOMIE ══ -->
|
||||
<div class="sh">
|
||||
<h2>1 · Anatomie von Konzept A</h2>
|
||||
<p>Eine Achse, sieben Bausteine. Die Zeit ist die Achse — Lebensereignisse & Jahre als zentrierte Pillen <i>unterbrechen</i> den Faden (Text wird nie von der Linie gekreuzt), Welt-Ereignisse legen sich als Bänder quer, Briefe verdichten sich adaptiv.</p>
|
||||
</div>
|
||||
|
||||
<div class="legend" style="margin-bottom:16px">
|
||||
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8">⚭</div><div><div class="ttl">Lebensereignis-Pille</div><div class="body">Geburt <b>*</b> · Tod <b>†</b> · Heirat <b>⚭</b>. Abgeleitet aus <code>Person</code>-Daten. Zentriert, gefüllt — unterbricht die Achse. Glyphen aus <code>personLifeDates.ts</code>.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8;color:#012851">★</div><div><div class="ttl">Kuratierte Ereignis-Pille</div><div class="body"><code>PERSONAL</code> — Umzug, Auswanderung. Mint-Rand. Editierbar im Kurator-Editor.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#EEEBE2;color:#607080;border:1px solid #607080">◍</div><div><div class="ttl">Welt-Band</div><div class="body"><code>HISTORICAL</code> — Krieg, Inflation. Gedämpftes Band quer über die Achse als Kontext.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8"></div><div><div class="ttl">Einzel-Brief</div><div class="body">Kleiner Punkt + Karte, alternierend links/rechts. Wurzel-Tag-Farbchip. Link zu <code>/documents/[id]</code>.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#a1dcd8;color:#012851;font-size:11px">▭</div><div><div class="ttl">Jahres-Strip</div><div class="body">Verdichtung dichter Jahre: Anzahl + 12-Monats-Sparkline. <code>MonthBucket</code> / <code>aggregateToYears</code>.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#f0efe9;border:1px dashed #c4c0ba;color:#9a958c;font-size:11px">⋯</div><div><div class="ttl">Lücke & Ohne-Datum</div><div class="body">Ruhige/leere Jahre als dünne Span-Zeile gefaltet; <code>UNKNOWN</code>-Briefe im „Ohne Datum"-Eimer am Ende.</div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="callout navy"><strong>Gruppierungs-Umschalter</strong> (oben rechts im Zeitstrahl): <span style="display:inline-flex;border:1.5px solid #012851;border-radius:5px;overflow:hidden;vertical-align:middle;margin:0 4px"><span style="background:#012851;color:#fff;font-size:9px;font-weight:700;padding:3px 11px">Datum</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Ereignis</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Thema</span></span> steuert <b>nur, wie lose Briefe gebündelt werden</b>. Lebensereignisse, kuratierte Ereignisse und Welt-Bänder bleiben in allen Modi gleich auf der Achse. Standard = <b>Datum</b>.</div>
|
||||
|
||||
|
||||
<!-- ══ 2 · GROUPING MODES ══ -->
|
||||
<div class="sh">
|
||||
<h2>2 · Die drei Gruppierungs-Modi</h2>
|
||||
<p>Gleicher Ausschnitt (1914–1915), dreimal gerendert. Nur die <b>losen Briefe</b> ordnen sich um — die Achse bleibt stabil. Schmale Spaltenbreite = Phone-/Lebensweg-Form derselben Komponente.</p>
|
||||
</div>
|
||||
|
||||
<div class="col3">
|
||||
|
||||
<!-- MODE: DATUM -->
|
||||
<div>
|
||||
<div class="modehd"><span class="seg">Datum</span> Chronologisch</div>
|
||||
<div class="modesub">Standard. Briefe nach Datum; dichte Jahre verdichten zum Strip. Reine Zeit-Reihung.</div>
|
||||
<div class="nf">
|
||||
<div class="nsp">
|
||||
<div class="nyr">1914</div>
|
||||
<div class="nnode"><span class="g">⚭</span><div class="lt">Heirat: Karl & Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
|
||||
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">1914–1918</div></div>
|
||||
<div class="nletter"><span class="d"></span><div class="ncard"><div class="t">✉ Kriegsausbruch — Brief an die Familie</div><div class="m">Karl → Elfriede · 4. Aug 1914</div></div></div>
|
||||
|
||||
<div class="nyr">1915</div>
|
||||
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
|
||||
<div class="nstrip">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px"><span style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</span><span style="font-size:7px;color:#8a8a86">Monate ▾</span></div>
|
||||
<div style="display:flex;align-items:flex-end;gap:1.5px;height:20px"><div style="flex:1;height:20%;background:#a1dcd8"></div><div style="flex:1;height:35%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:70%;background:#a1dcd8"></div><div style="flex:1;height:60%;background:#a1dcd8"></div><div style="flex:1;height:85%;background:#a1dcd8"></div><div style="flex:1;height:100%;background:#a1dcd8"></div><div style="flex:1;height:88%;background:#a1dcd8"></div><div style="flex:1;height:72%;background:#a1dcd8"></div><div style="flex:1;height:48%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:40%;background:#a1dcd8"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE: EREIGNIS -->
|
||||
<div>
|
||||
<div class="modehd"><span class="seg">Ereignis</span> Kuratiert</div>
|
||||
<div class="modesub">Briefe bündeln unter kuratierte Ereignisse (<code>TimelineEvent.documents</code>). Erzählende Cluster statt Listen.</div>
|
||||
<div class="nf">
|
||||
<div class="nsp">
|
||||
<div class="nyr">1914</div>
|
||||
<div class="nnode"><span class="g">⚭</span><div class="lt">Heirat: Karl & Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
|
||||
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">1914–1918</div></div>
|
||||
|
||||
<div class="nyr">1915</div>
|
||||
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
|
||||
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Briefe von der Front · 24</div><div class="m">Karl ⇄ Elfriede & Hans · 1915 ▾</div><span class="chip krieg" style="margin-top:4px"><i></i>Krieg</span></div></div>
|
||||
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Weihnachten 1915 · 3</div><div class="m">kuratiertes Ereignis ▾</div><span class="chip weih" style="margin-top:4px"><i></i>Weihnachten</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODE: THEMA -->
|
||||
<div>
|
||||
<div class="modehd"><span class="seg">Thema</span> Nach Wurzel-Tag</div>
|
||||
<div class="modesub">Optional (Post-MVP). Lose Briefe je Jahr in Wurzel-Tag-Eimer; Mehrfach-Tags dedupliziert auf den primären.</div>
|
||||
<div class="nf">
|
||||
<div class="nsp">
|
||||
<div class="nyr">1914</div>
|
||||
<div class="nnode"><span class="g">⚭</span><div class="lt">Heirat: Karl & Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
|
||||
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">1914–1918</div></div>
|
||||
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg</span><span style="font-size:8px;color:#8a8a86">6 ▾</span></div>
|
||||
|
||||
<div class="nyr">1915</div>
|
||||
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
|
||||
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg › Briefe von der Front</span><span style="font-size:8px;color:#8a8a86">24 ▾</span></div>
|
||||
<div class="nbucket"><span class="chip weih" style="margin:0"><i></i>Weihnachten</span><span style="font-size:8px;color:#8a8a86">3 ▾</span></div>
|
||||
<div class="nbucket"><span class="chip fam" style="margin:0"><i></i>Familie</span><span style="font-size:8px;color:#8a8a86">2 ▾</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note" style="margin-top:8px">Hinweis im UI: „Brief mit mehreren Tags erscheint unter seinem primären Tag."</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 3 · ALL CASES PREVIEW ══ -->
|
||||
<div class="sh">
|
||||
<h2>3 · Vollständige Vorschau — alle Dichte-Fälle</h2>
|
||||
<p>Ein durchgehender Zeitstrahl (Desktop, zentrale Achse) von 1899 bis „Ohne Datum". Jeder Dichte-Fall kommt genau einmal vor — von leeren Jahren bis zu hunderten Briefen.</p>
|
||||
</div>
|
||||
|
||||
<div class="callout navy" style="margin-bottom:18px">
|
||||
<strong>Abgedeckte Fälle:</strong>
|
||||
① leere Jahre (gefaltete Lücke) ·
|
||||
② wenige Briefe ≤ 3 (einzelne Karten) ·
|
||||
③ hunderte Briefe (Jahres-Strip + Sparkline) ·
|
||||
④ kuratiertes Ereignis & Welt-Band ·
|
||||
⑤ ungenaue Präzision (<code>Sommer</code>, <code>ca.</code>) ·
|
||||
⑥ undatierte Briefe (Ohne-Datum-Eimer).
|
||||
</div>
|
||||
|
||||
<div class="tl-canvas">
|
||||
<div class="dh">Zeitstrahl</div>
|
||||
<div class="dh-sub">Die Familie Raddatz · 1899–1950 · 412 Briefe · 38 Ereignisse · <span style="color:#012851">Gruppierung: Datum</span></div>
|
||||
|
||||
<div style="text-align:center;margin-bottom:8px"><span class="casetag">① Leere Jahre → gefaltet</span></div>
|
||||
<div class="axis">
|
||||
|
||||
<!-- ① empty years gap -->
|
||||
<div class="gap"><span class="ln"></span><b>1899 – 1908</b> · keine Einträge<span class="ln"></span></div>
|
||||
|
||||
<!-- ② 3 letters -->
|
||||
<div class="ybadge"><span>1909</span></div>
|
||||
<div style="text-align:center;margin-bottom:4px"><span class="casetag">② Wenige Briefe → einzeln</span></div>
|
||||
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief aus Stettin</div><div class="m">Elfriede → Karl · Mai 1909</div><span class="chip fam"><i></i>Familie</span></div></div><div class="dot"></div><div class="half b"></div></div>
|
||||
<div class="lrow"><div class="half a"></div><div class="dot"></div><div class="half b"><div class="lcard"><div class="t">✉ Geburtstagsgruß</div><div class="m">Karl → Hans · Sep 1909</div></div></div></div>
|
||||
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief zum Jahresende</div><div class="m">Karl → Elfriede · Dez 1909</div><span class="chip weih"><i></i>Weihnachten</span></div></div><div class="dot"></div><div class="half b"></div></div>
|
||||
|
||||
<!-- ④ life event + world band -->
|
||||
<div class="ybadge"><span>1914</span></div>
|
||||
<div class="pill"><span class="inner"><span class="gly">⚭</span><span class="tx"><span class="t">Heirat: Karl & Elfriede Raddatz</span><span class="s">1914 · abgeleitet aus Beziehung</span></span></span></div>
|
||||
<div style="text-align:center;margin-bottom:6px"><span class="casetag">④ Welt-Band (RANGE 1914–1918)</span></div>
|
||||
<div class="wband"><span class="t">◍ Erster Weltkrieg</span><span class="s">1914–1918 · historisch · 187 Briefe in dieser Zeit</span></div>
|
||||
|
||||
<!-- ③ hundreds of letters strip -->
|
||||
<div class="ybadge"><span>1915</span></div>
|
||||
<div style="text-align:center;margin-bottom:6px"><span class="casetag">③ Hunderte Briefe → Jahres-Strip</span> <span class="casetag" style="background:#607080">⑤ „Sommer 1915"</span></div>
|
||||
<div class="pill"><span class="inner"><span class="gly">*</span><span class="tx"><span class="t">Geburt: Hans Raddatz</span><span class="s">Sommer 1915 · abgeleitet · SEASON</span></span></span></div>
|
||||
<div class="strip">
|
||||
<div class="hd"><span class="ct">✉ 187 Briefe</span><span class="ex">Monats-Dichte · antippen → Monate → Briefe ▾</span></div>
|
||||
<div class="spark"><div style="height:22%"></div><div style="height:30%"></div><div style="height:48%"></div><div style="height:66%"></div><div style="height:58%"></div><div style="height:80%"></div><div style="height:100%"></div><div style="height:92%"></div><div style="height:74%"></div><div style="height:50%"></div><div style="height:44%"></div><div style="height:34%"></div></div>
|
||||
<div class="axl"><span>Jan 1915</span><span>Dez 1915</span></div>
|
||||
</div>
|
||||
|
||||
<!-- empty-ish span with some letters collapsed -->
|
||||
<div class="gap"><span class="ln"></span><b>1916 – 1922</b> · Nachkriegsjahre · 96 Briefe<span class="ln"></span></div>
|
||||
|
||||
<!-- ⑤ approx precision world band -->
|
||||
<div style="text-align:center;margin-bottom:6px"><span class="casetag" style="background:#607080">⑤ „ca. 1923" → APPROX</span></div>
|
||||
<div class="wband"><span class="t">◍ Hyperinflation</span><span class="s">ca. 1923 · historisch</span></div>
|
||||
|
||||
<!-- curated personal event -->
|
||||
<div class="ybadge"><span>1924</span></div>
|
||||
<div class="pill curated"><span class="inner"><span class="gly">★</span><span class="tx"><span class="t">Auswanderung nach Argentinien</span><span class="s">Frühjahr 1924 · persönlich · kuratiert</span></span></span></div>
|
||||
|
||||
<!-- tail empty -->
|
||||
<div class="gap"><span class="ln"></span><b>1925 – 1950</b> · keine Einträge<span class="ln"></span></div>
|
||||
</div>
|
||||
|
||||
<!-- ⑥ undated bucket -->
|
||||
<div style="text-align:center;margin:6px 0"><span class="casetag" style="background:#7a756c">⑥ Undatiert → eigener Eimer am Ende</span></div>
|
||||
<div class="undated">
|
||||
<div class="h">Ohne Datum · 11 Briefe</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-top:1px solid #f0eee8"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Brief ohne Jahresangabe</span><span style="font-size:8px;color:#aaa">Präzision UNKNOWN</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:4px 0"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Fragment, Absender unklar</span><span style="font-size:8px;color:#aaa">+ 9 weitere ▾</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">Kein erfundenes Datum: undatierte Briefe wandern nie spekulativ in ein Jahr, sondern bleiben sichtbar im Eimer. <code>RANGE</code>-Einträge (Krieg) erscheinen einmal im Start-Jahr mit Spannen-Marker, nicht in jedem überspannten Jahr.</div>
|
||||
|
||||
|
||||
<!-- ══ 4 · PRECISION ══ -->
|
||||
<div class="sh"><h2>4 · Datums-Präzision (geteilt von Ereignissen & Briefen)</h2><p>Eine Render-Logik für alle datierten Einträge — <code>dateLabel.ts</code>, gespeist von <code>DatePrecision</code>.</p></div>
|
||||
<div class="rules">
|
||||
<table>
|
||||
<tr><th>DatePrecision</th><th>Darstellung</th><th>Beispiel</th><th>Wirkung auf der Achse</th></tr>
|
||||
<tr><td>DAY</td><td>vollständiges Datum</td><td class="ex">28. Juli 1914</td><td>exakte Sortierung im Jahres-Band</td></tr>
|
||||
<tr><td>MONTH</td><td>Monat + Jahr</td><td class="ex">Juli 1914</td><td>Monats-Sortierung</td></tr>
|
||||
<tr><td>SEASON</td><td>Jahreszeit + Jahr</td><td class="ex">Sommer 1914</td><td>grobe Reihung</td></tr>
|
||||
<tr><td>YEAR</td><td>nur Jahr</td><td class="ex">1914</td><td>ans Band-Ende</td></tr>
|
||||
<tr><td>APPROX</td><td>„ca." + Jahr</td><td class="ex">ca. 1914</td><td>mit „ca."-Marker</td></tr>
|
||||
<tr><td>RANGE</td><td>Start–Ende</td><td class="ex">1914–1918</td><td>Start-Jahr, Spannen-Marker, nicht dupliziert</td></tr>
|
||||
<tr><td>UNKNOWN</td><td>undatiert</td><td class="ex">Ohne Datum</td><td>eigener Eimer am Ende</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 5 · RESPONSIVE ══ -->
|
||||
<div class="sh"><h2>5 · Responsiv — eine Komponente, drei Breiten</h2><p>Identisches Markup & identische Daten. Nur die Achs-Position wechselt per Container-Query.</p></div>
|
||||
<div class="legend" style="grid-template-columns:repeat(3,1fr)">
|
||||
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px">▏</div><div><div class="ttl">≥ 1024px · Desktop</div><div class="body"><b>Zentrale Achse</b>, Briefe alternierend links/rechts, Welt-Bänder über volle Breite. Pillen unterbrechen die Linie.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px">▎</div><div><div class="ttl">< 1024px · Phone</div><div class="body"><b>Linke Achse</b>, alles einseitig rechts. DOM-Reihenfolge bleibt streng chronologisch (<code><ol></code>) — Screenreader liest linear.</div></div></div>
|
||||
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px">▎</div><div><div class="ttl">Lebensweg-Rail · 35%</div><div class="body">Gleiche linke Achse in der Personenseite (<code><TimelineView personId></code>), gefiltert auf eine Person. Rail-Tauglichkeit = Stärke von A.</div></div></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 6 · TOKENS ══ -->
|
||||
<div class="sh"><h2>6 · Design-Tokens (echte, ausgelieferte Werte)</h2><p>Aus <code>frontend/src/routes/layout.css</code>. Keine Hardcodes in der Komponente.</p></div>
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<tr><th>Rolle</th><th>Token</th><th>Wert</th><th>Einsatz</th></tr>
|
||||
<tr><td>Achse / Knoten / Header</td><td><code>brand-navy</code></td><td><span class="sw" style="background:#012851"></span>#012851</td><td>Spine, Lebensereignis-Pillen, Jahres-Badges, Titel</td></tr>
|
||||
<tr><td>Akzent / Brief-Punkt</td><td><code>brand-mint</code></td><td><span class="sw" style="background:#a1dcd8"></span>#a1dcd8</td><td>Brief-Punkte, kuratierte Pillen-Ränder, Sparkline, Dark-Mode-Auswahl</td></tr>
|
||||
<tr><td>Historisch / Welt</td><td><code>tag-slate</code></td><td><span class="sw" style="background:#607080"></span>#607080</td><td>Welt-Bänder & Glyphe ◍ — gedämpft</td></tr>
|
||||
<tr><td>Tag-Chip-Farben</td><td><code>--c-tag-*</code> (Wurzel)</td><td><span class="sw" style="background:#5a8a6a"></span><span class="sw" style="background:#a0522d"></span><span class="sw" style="background:#c17a00"></span><span class="sw" style="background:#7a4f9a"></span></td><td>sage · sienna · amber · violet — Farbe vom Wurzel-Tag, Punkt + Label</td></tr>
|
||||
<tr><td>Seite / Karte / Linie</td><td><code>canvas · surface · line</code></td><td><span class="sw" style="background:#f0efe9"></span><span class="sw" style="background:#fff"></span><span class="sw" style="background:#e4e2d7"></span></td><td>#f0efe9 · #ffffff · #e4e2d7</td></tr>
|
||||
<tr><td>Text sekundär</td><td><code>text-ink-3</code></td><td><span class="sw" style="background:#6b7280"></span>#6b7280</td><td>Meta-Zeilen (4,8:1 auf weiß — AA ✓)</td></tr>
|
||||
<tr><td>Schrift</td><td><code>font-serif · font-sans</code></td><td>Tinos · Montserrat</td><td>Namen/Titel serif · Labels/Chrome sans</td></tr>
|
||||
<tr><td>Lebensdaten-Glyphen</td><td><code>personLifeDates.ts</code></td><td>* † ⚭</td><td>Geburt · Tod · Heirat — konsistent mit Personenkarten</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══ 7 · IMPL-REF ══ -->
|
||||
<div class="sh"><h2>7 · Implementierungs-Referenz & Barrierefreiheit</h2><p>Domain-Ordner <code>frontend/src/lib/timeline/</code>; Route <code>/zeitstrahl</code>; Backend <code>GET /api/timeline</code>.</p></div>
|
||||
<div class="impl-ref">
|
||||
<table>
|
||||
<tr><th>Baustein</th><th>Komponente / Datei</th><th>Verantwortung</th></tr>
|
||||
<tr><td>Orchestrator</td><td><code>TimelineView.svelte</code></td><td>Lädt <code>/api/timeline</code>; optionaler <code>personId</code> für globalen vs. Lebensweg-Modus; hält den Gruppierungs-Modus</td></tr>
|
||||
<tr><td>Jahres-Band</td><td><code>YearBand.svelte</code></td><td>Jahres-Badge + Einträge; Lücken-Faltung ruhiger Spannen</td></tr>
|
||||
<tr><td>Ereignis-Pille</td><td><code>EventCard.svelte</code></td><td>PERSONAL / HISTORICAL / abgeleitet; zentrierte Pille bzw. Welt-Band; präzisions-bewusstes Label</td></tr>
|
||||
<tr><td>Brief-Karte</td><td><code>LetterCard.svelte</code></td><td>Einzel-Brief, alternierende Seite, Wurzel-Tag-Chip, Link <code>/documents/[id]</code></td></tr>
|
||||
<tr><td>Jahres-Strip</td><td><code>YearLetterStrip.svelte</code></td><td>Adaptive Verdichtung ab Schwellwert; 12-Monats-Sparkline aus <code>MonthBucket</code> / <code>aggregateToYears</code> (<code>lib/document/timeline.ts</code>)</td></tr>
|
||||
<tr><td>Datums-Helfer</td><td><code>dateLabel.ts</code></td><td><code>DatePrecision</code> → deutsches Label; geteilt von Ereignissen & Briefen</td></tr>
|
||||
<tr><td>Kurator-Editor</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td>Ereignis anlegen/bearbeiten; Personen- + Dokument-Mehrfach-Picker (Bulk-Linking); <code>WRITE_ALL</code></td></tr>
|
||||
<tr><td>Quick-Add</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Auf <code>/documents/[id]</code>: Ereignis wählen/neu anlegen; verlinkt einen Brief</td></tr>
|
||||
<tr><td>Daten-API</td><td><code>GET /api/timeline</code></td><td>Verschmilzt kuratierte + abgeleitete Ereignisse + Briefe in <code>TimelineDTO</code> (Jahres-Eimer + Ohne-Datum); Filter <code>personId · type · fromYear · toYear</code></td></tr>
|
||||
<tr><td>Barrierefreiheit</td><td>—</td><td>Achse = <code><ol></code>, chronologische DOM-Reihenfolge; ◍ ✉ * nie nur Farbe — Glyphe + Label; 44px-Tap-Ziele; <code>prefers-reduced-motion</code>; axe in Light & Dark</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1061
docs/specs/zeitstrahl-global-concepts.html
Normal file
1061
docs/specs/zeitstrahl-global-concepts.html
Normal file
File diff suppressed because it is too large
Load Diff
182
docs/superpowers/specs/2026-06-07-family-timeline-design.md
Normal file
182
docs/superpowers/specs/2026-06-07-family-timeline-design.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Family Timeline (Zeitstrahl) — Design Spec
|
||||
|
||||
**Date:** 2026-06-07
|
||||
**Status:** Approved — pending implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
The archive can capture, transcribe, organize, and browse letters, but the transcribed material does not yet add up to a *story in time*. Readers (younger, phone-first) have no way to feel the family's history unfold; transcribers don't see their work become something larger. A previous attempt to derive meaning automatically (LLM search) was slow and low-quality, so the family is wary of auto-extraction from handwriting.
|
||||
|
||||
## Goal
|
||||
|
||||
A **hand-curated, year-banded vertical timeline** — the "Zeitstrahl" — that weaves three layers into one chronological view:
|
||||
|
||||
1. **Person life-events** derived from already-curated structured data (`Person` birth/death dates, marriage years from `PersonRelationship.fromYear`). Trusted, free, no extra entry. (Requires the Person birth/death fields to move from year-integers to date + precision — see foundational issue 1.)
|
||||
2. **Hand-curated events** the family writes — both **personal** (a move, an illness, emigration) and **historical** (a war, hyperinflation). Editorially controlled, always correct.
|
||||
3. **Letters**, auto-placed by their existing `documentDate`, optionally hand-linked to an event to cluster them.
|
||||
|
||||
Two surfaces, one component:
|
||||
- **Global timeline** at `/zeitstrahl`.
|
||||
- **Per-person "Lebensweg"** — the same view filtered to one person, embedded on the Person detail page.
|
||||
|
||||
Built for phones (vertical scroll), honest about date precision, with no fabricated dates.
|
||||
|
||||
### Non-goals (YAGNI)
|
||||
|
||||
- ❌ Auto-extracting events from transcription text — explicitly avoided; this is what makes the feature trustworthy.
|
||||
- ❌ Importing an external historical-events dataset — historical events are hand-entered too.
|
||||
- ❌ A map / geographic view — that is a separate future feature (B2).
|
||||
- ❌ Per-derived-event hide/override toggle — deferred refinement; MVP shows all derived events.
|
||||
- ❌ Day-resolution timeline axis — the axis is the **year**; finer dates only affect within-band ordering and label text.
|
||||
|
||||
## Core principle: the year is the axis
|
||||
|
||||
Most dates in the archive are year-only (birth/death/marriage years are years by nature; many letters carry `YEAR`/`APPROX` precision). Therefore:
|
||||
|
||||
- The timeline spine is a **sequence of year bands**. Everything for a given year lives in that band.
|
||||
- **Finer ordering only when we have it.** A `DAY`-precision letter (`1923-04-12`) sorts above a `YEAR`-precision one (`1923`) *within* the 1923 band; we never invent a day we don't have.
|
||||
- **An "Ohne Datum" bucket** at the end holds items with `UNKNOWN` precision.
|
||||
- **Honest precision rendering** reuses the existing `DatePrecision` enum for every dated item (events and letters share one rendering path).
|
||||
|
||||
### Date rendering (shared by events and letters)
|
||||
|
||||
| `DatePrecision` | German render | Example |
|
||||
|---|---|---|
|
||||
| `DAY` | full date | `28. Juli 1914` |
|
||||
| `MONTH` | month + year | `Juli 1914` |
|
||||
| `SEASON` | season + year | `Sommer 1914` |
|
||||
| `YEAR` | year only | `1914` |
|
||||
| `APPROX` | "ca." + year | `ca. 1914` |
|
||||
| `RANGE` | start–end year | `1914–1918` |
|
||||
| `UNKNOWN` | undated bucket | `Ohne Datum` |
|
||||
|
||||
A `RANGE` item is shown in its **start year's band** with a span marker; it is not duplicated across every year it covers.
|
||||
|
||||
## Data model
|
||||
|
||||
A new `timeline/` domain package on the backend (kept deliberately separate from the in-flight Lesereisen/`Geschichte` work in #750–753).
|
||||
|
||||
### `TimelineEvent` entity
|
||||
|
||||
Mirrors the `Document` date model for consistency, so events and letters use one date-handling code path.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `UUID` | `@GeneratedValue(UUID)` |
|
||||
| `title` | `String` | required |
|
||||
| `type` | `EventType` enum | `PERSONAL`, `HISTORICAL` |
|
||||
| `eventDate` | `LocalDate` | required — most precise date known (WW1 → `1914-07-28`; vague year → `1920-01-01`) |
|
||||
| `precision` | `DatePrecision` | reuse existing enum; default `YEAR` — governs rendering & whether the day matters |
|
||||
| `eventDateEnd` | `LocalDate` (nullable) | only set when `precision == RANGE` |
|
||||
| `description` | `TEXT` (nullable) | free-text narrative for the event |
|
||||
| `persons` | ManyToMany `Person` | who the event involves; drives the per-person view & filtering |
|
||||
| `documents` | ManyToMany `Document` | optional hand-linked supporting letters (the "cluster letters to an event" feature) |
|
||||
| `createdBy` / `createdAt` / `updatedBy` / `updatedAt` / `version` | audit | standard entity conventions |
|
||||
|
||||
- `@Schema(requiredMode = REQUIRED)` on every always-populated field (`id`, `title`, `type`, `eventDate`, `precision`).
|
||||
- Collections use `@Builder.Default new HashSet<>()`.
|
||||
- New Flyway migration adds `timeline_events`, `timeline_event_persons`, `timeline_event_documents` join tables.
|
||||
|
||||
### `EventType` enum
|
||||
|
||||
`PERSONAL` | `HISTORICAL`. Personal events render with a person/family accent; historical events with a "world" accent and muted styling so the two layers are visually separable.
|
||||
|
||||
### Prerequisite: migrate `Person` birth/death to date + precision
|
||||
|
||||
Today `Person` stores `birthYear`/`deathYear` as `Integer`, so a known exact birthday (e.g. `1901-03-14`) has nowhere to live and derived events are stuck at year precision. This is fixed by a **foundational Person-domain migration** that the timeline depends on (and which delivers value on its own — precise dates then render on person cards, hover cards, and the Stammbaum).
|
||||
|
||||
**Change:** replace `birthYear`/`deathYear` (`Integer`) with:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `birthDate` | `LocalDate` (nullable) | most precise date known |
|
||||
| `birthDatePrecision` | `DatePrecision` (nullable) | `YEAR` for year-only, `DAY` for exact birthdays, etc. |
|
||||
| `deathDate` | `LocalDate` (nullable) | |
|
||||
| `deathDatePrecision` | `DatePrecision` (nullable) | |
|
||||
|
||||
**Flyway data migration:** existing `birth_year` → `birth_date = '{year}-01-01'`, `birth_date_precision = 'YEAR'` (same for death); then drop the year columns.
|
||||
|
||||
**Re-import preservation (ADR-025):** the canonical importer (`PersonRegisterImporter` / `tools/import-normalizer/persons_tree.py`) only carries the *year*. On re-import it must **not** clobber a hand-entered finer-than-`YEAR` date — if the existing precision is `DAY`/`MONTH`/`SEASON`, preserve it; only refresh from the spreadsheet year when the field is empty or still `YEAR`-from-import.
|
||||
|
||||
**Bounding the blast radius:** `PersonNodeDTO` keeps exposing an `Integer birthYear`/`deathYear` *derived* from the new date (`birthDate.getYear()`), so the Stammbaum layout (`familyForest.ts` et al.) is untouched. Display surfaces (person card, hover card) move to a shared precision-aware formatter — extend the existing `frontend/src/lib/person/personLifeDates.ts`. The person edit/new forms gain date inputs with a precision selector.
|
||||
|
||||
**Scope note:** `PersonRelationship.fromYear` (marriage year) stays `Integer`/`YEAR` for MVP — precise marriage dates are a later, parallel extension if wanted.
|
||||
|
||||
### Derived person-events (not persisted)
|
||||
|
||||
Assembled on read from the migrated `Person` data; never stored:
|
||||
|
||||
| Source | Derived event | `eventDate` | precision |
|
||||
|---|---|---|---|
|
||||
| `Person.birthDate` | *Geburt: {name}* | `Person.birthDate` | `Person.birthDatePrecision` |
|
||||
| `Person.deathDate` | *Tod: {name}* | `Person.deathDate` | `Person.deathDatePrecision` |
|
||||
| `PersonRelationship` `SPOUSE_OF.fromYear` | *Heirat: {A} & {B}* | `{fromYear}-01-01` | `YEAR` |
|
||||
|
||||
Emitted in the same DTO shape as a curated event, flagged `derived: true`, `type = PERSONAL`. They cannot be edited from the timeline (they are edited at their source: Person record / relationship). A marriage is derived once per `SPOUSE_OF` edge (symmetric edges are stored once — see existing relationship rules).
|
||||
|
||||
### Letters
|
||||
|
||||
Placed by `Document.documentDate`:
|
||||
- Band = `documentDate.getYear()`; `UNKNOWN` precision → "Ohne Datum" bucket.
|
||||
- Sub-ordered within a band by full date when precision allows.
|
||||
- A letter may also appear under an event it's linked to (via `TimelineEvent.documents`) as a cluster, in addition to its own band placement.
|
||||
|
||||
## Assembly & API
|
||||
|
||||
A `TimelineService` merges the three layers into a year-bucketed DTO for the requested scope and filters. Layering rules apply: the service owns `TimelineEventRepository` and reaches Person/Document/Relationship data through their **services**, never their repositories.
|
||||
|
||||
### DTOs
|
||||
|
||||
- `TimelineEntryDTO` — one renderable item: `kind` (`EVENT` | `LETTER`), `eventDate`, `precision`, `eventDateEnd`, `title`, `type` (for events), `derived` flag, plus the source id (eventId / documentId) and minimal display fields (sender/receiver names for letters, linked person ids for events).
|
||||
- `TimelineYearDTO` — `{ year: int, entries: TimelineEntryDTO[] }`.
|
||||
- `TimelineDTO` — `{ years: TimelineYearDTO[], undated: TimelineEntryDTO[] }`.
|
||||
|
||||
### Endpoints
|
||||
|
||||
- `GET /api/timeline` — global timeline. Query params (all optional): `personId`, `generation`, `type` (`PERSONAL`/`HISTORICAL`), `fromYear`, `toYear`. The per-person "Lebensweg" is just `GET /api/timeline?personId=…` — no separate endpoint. Requires `READ_ALL`.
|
||||
- `POST /api/timeline/events` — create a curated event. `@RequirePermission(Permission.WRITE_ALL)`.
|
||||
- `PUT /api/timeline/events/{id}` — update. `@RequirePermission(Permission.WRITE_ALL)`.
|
||||
- `DELETE /api/timeline/events/{id}` — delete. `@RequirePermission(Permission.WRITE_ALL)`.
|
||||
- `GET /api/timeline/events/{id}` — fetch a single event for the edit form. Requires `READ_ALL`.
|
||||
|
||||
Input DTO `TimelineEventRequest` lives flat in the `timeline/` package. Errors use `DomainException.notFound/...`; **no new `ErrorCode`** is required. Run `npm run generate:api` after backend model/endpoint changes.
|
||||
|
||||
## Frontend
|
||||
|
||||
- New domain dir `frontend/src/lib/timeline/`:
|
||||
- `TimelineView.svelte` — orchestrator; accepts an optional `personId` prop so the same component powers both global and per-person views.
|
||||
- `YearBand.svelte` — one year section header + its entries.
|
||||
- `EventCard.svelte` — renders a `PERSONAL`/`HISTORICAL`/derived event with precision-aware date label.
|
||||
- `LetterCard.svelte` — compact letter row (sender → receiver, snippet/title, date), links to `/documents/[id]`.
|
||||
- `TimelineFilters.svelte` — person, generation, layer toggles, year range.
|
||||
- `dateLabel.ts` — the shared precision→label helper (reuse/extend `lib/document/timeline.ts` helpers like `formatTickLabel` where they fit).
|
||||
- Routes:
|
||||
- `/zeitstrahl` — global timeline (`+page.server.ts` loads `/api/timeline`).
|
||||
- `/zeitstrahl/events/new` and `/zeitstrahl/events/[id]/edit` — curator forms, gated to `WRITE_ALL`, using the form-actions pattern.
|
||||
- Person detail page gains a **Lebensweg** card section embedding `<TimelineView personId={person.id} />`.
|
||||
- Styling per project conventions (card pattern, brand tokens, `font-serif` for names/titles, `BackButton`, mobile-first at 375px, dark-mode tokens).
|
||||
- i18n keys added to `messages/{de,en,es}.json` (German primary).
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend: `TimelineService` assembly/merge/sort/precision-bucketing (unit + `@DataJpaTest` against Postgres via Testcontainers); controller permission gating; derived-event assembly (birth/death/marriage, symmetric marriage dedup).
|
||||
- Frontend: `dateLabel.ts` precision rendering; `TimelineView` global vs `personId` modes (`*.svelte.spec.ts`); filter behavior.
|
||||
- Follow project test discipline: targeted single-file runs locally only; full sweep left to CI.
|
||||
|
||||
## Proposed issue breakdown (milestone "Zeitstrahl / Family Timeline")
|
||||
|
||||
Ordered so each issue is independently shippable and reviewable; later issues depend on earlier ones. Issue 1 is a standalone Person-domain improvement and a hard prerequisite for the timeline's derived events.
|
||||
|
||||
1. **Person birth/death → date + precision (foundational)** — replace `birthYear`/`deathYear` with `birthDate`/`deathDate` + precision on `Person`; Flyway data migration (year → `YYYY-01-01`, `YEAR`); update importer with re-import preservation rule; derive year in `PersonNodeDTO` (Stammbaum untouched); move person card / hover card to a precision-aware `personLifeDates.ts`; add date+precision inputs to person new/edit forms. Ships value on its own.
|
||||
2. **Backend: `TimelineEvent` entity + migration** — entity, `EventType`, Flyway migration + join tables, repository.
|
||||
3. **Backend: TimelineEvent CRUD API** — `TimelineEventController` + `TimelineService` write methods, `TimelineEventRequest` DTO, permission gating, `GET /events/{id}`.
|
||||
4. **Backend: derived person-events** — assemble Geburt/Tod/Heirat from migrated Person + relationship data via their services; unit-tested dedup.
|
||||
5. **Backend: timeline assembly endpoint** — `GET /api/timeline` merging events + derived events + letters into `TimelineDTO`; year-bucketing, precision sort, undated bucket, filters.
|
||||
6. **Frontend: shared date-label helper + types** — `dateLabel.ts`, regen API types.
|
||||
7. **Frontend: global `/zeitstrahl` view** — `TimelineView`, `YearBand`, `EventCard`, `LetterCard`, server load.
|
||||
8. **Frontend: filters** — `TimelineFilters` (person / generation / layer / year range).
|
||||
9. **Frontend: curator event forms** — `/zeitstrahl/events/new` + `/[id]/edit`, gated, with document & person pickers.
|
||||
10. **Frontend: per-person Lebensweg** — embed `<TimelineView personId>` on Person detail.
|
||||
11. **Polish & a11y** — mobile layout at 375px, dark mode, axe checks, i18n completeness (de/en/es).
|
||||
|
||||
> An ADR may be warranted for the new `timeline/` domain + entity (per `docs/CLAUDE.md`, significant data-model change). Add as the next sequential ADR number when implementation starts.
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -13,6 +13,9 @@ node_modules
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Leftover directory from branch work
|
||||
/src.main/
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -7,6 +7,7 @@ bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
/src.main/
|
||||
|
||||
# Build artifacts
|
||||
/.svelte-kit/
|
||||
@@ -19,6 +20,7 @@ bun.lockb
|
||||
/src/lib/paraglide/
|
||||
/src/lib/paraglide_bak*/
|
||||
/src/paraglide/
|
||||
/src.main/
|
||||
/project.inlang/
|
||||
|
||||
# Test artifacts
|
||||
|
||||
@@ -174,12 +174,18 @@
|
||||
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
|
||||
"person_label_notes": "Notizen",
|
||||
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
||||
"person_label_birth_year": "Geburtsjahr",
|
||||
"person_label_death_year": "Todesjahr",
|
||||
"person_label_birth_date": "Geburtsdatum",
|
||||
"person_label_death_date": "Sterbedatum",
|
||||
"person_label_birth_date_precision": "Genauigkeit",
|
||||
"person_label_death_date_precision": "Genauigkeit",
|
||||
"person_precision_hint": "Wie genau ist dieses Datum bekannt?",
|
||||
"person_precision_day": "Genaues Datum (Tag)",
|
||||
"person_precision_month": "Monat bekannt",
|
||||
"person_precision_year": "Nur Jahreszahl",
|
||||
"person_date_placeholder_hint": "Leer lassen, wenn unbekannt",
|
||||
"person_label_generation": "Generation",
|
||||
"person_option_generation_unset": "(keine)",
|
||||
"person_hint_generation": "Generation in der Familie (G 0 = älteste Generation)",
|
||||
"person_placeholder_year": "z.B. 1923",
|
||||
"person_year_error": "Bitte eine vierstellige Jahreszahl eingeben",
|
||||
"person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen",
|
||||
"person_docs_heading": "Gesendete Dokumente",
|
||||
@@ -301,6 +307,8 @@
|
||||
"comp_multiselect_placeholder": "Namen tippen...",
|
||||
"comp_multiselect_remove": "Entfernen",
|
||||
"comp_multiselect_loading": "Suche...",
|
||||
"comp_typeahead_error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut.",
|
||||
"comp_typeahead_no_results": "Keine Treffer",
|
||||
"comp_taginput_placeholder_create": "Schlagworte hinzufügen...",
|
||||
"comp_taginput_placeholder_filter": "Nach Schlagworten filtern...",
|
||||
"comp_taginput_remove": "Schlagwort entfernen",
|
||||
@@ -641,6 +649,8 @@
|
||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||
"error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.",
|
||||
"error_birth_after_death": "Geburtsdatum muss vor dem Sterbedatum liegen. Tipp: Falls nur das Todesjahr bekannt ist und der Geburtstag spät im selben Jahr lag, bitte das Folgejahr eintragen.",
|
||||
"error_invalid_date_precision": "Datum und Genauigkeit stimmen nicht überein.",
|
||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||
@@ -1023,6 +1033,10 @@
|
||||
"nav_stammbaum": "Stammbaum",
|
||||
"nav_geschichten": "Geschichten",
|
||||
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
|
||||
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
|
||||
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.",
|
||||
"error_journey_at_capacity": "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht.",
|
||||
"journey_item_document_deleted": "[Dokument gelöscht]",
|
||||
"geschichten_index_title": "Geschichten",
|
||||
"geschichten_new_button": "Neue Geschichte",
|
||||
"geschichten_filter_all_pill": "Alle",
|
||||
@@ -1033,10 +1047,19 @@
|
||||
"geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.",
|
||||
"geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.",
|
||||
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
|
||||
"geschichten_filter_document_chip": "Gefiltert nach Brief:",
|
||||
"geschichten_filter_remove_document_chip": "Brief {title} aus Filter entfernen",
|
||||
"geschichten_empty_for_document": "Noch keine Geschichten zu diesem Brief",
|
||||
"geschichten_published_heading": "Veröffentlicht",
|
||||
"geschichten_drafts_heading": "Entwürfe",
|
||||
"geschichten_draft_badge": "Entwurf",
|
||||
"geschichten_drafts_unfiltered_caption": "(alle Entwürfe)",
|
||||
"geschichten_back_to_index": "Zurück zu Geschichten",
|
||||
"geschichten_published_on": "veröffentlicht am {date}",
|
||||
"journey_compiled_on": "zusammengestellt am {date}",
|
||||
"geschichten_persons_section": "Personen in dieser Geschichte",
|
||||
"geschichten_documents_section": "Erwähnte Dokumente",
|
||||
"geschichten_document_link_placeholder": "Dokument öffnen",
|
||||
"geschichten_card_heading": "Geschichten",
|
||||
"geschichten_card_write_action": "+ Geschichte schreiben",
|
||||
"geschichten_card_attach_action": "+ Geschichte anhängen",
|
||||
@@ -1044,6 +1067,7 @@
|
||||
"geschichten_card_show_all": "Alle anzeigen",
|
||||
"geschichte_editor_title_placeholder": "Titel der Geschichte",
|
||||
"geschichte_editor_body_placeholder": "Schreibe hier deine Geschichte…",
|
||||
"geschichte_sidebar_status": "Status",
|
||||
"geschichte_editor_status_draft": "ENTWURF",
|
||||
"geschichte_editor_status_published": "VERÖFFENTLICHT",
|
||||
"geschichte_editor_status_draft_hint": "Noch nicht öffentlich sichtbar.",
|
||||
@@ -1058,8 +1082,17 @@
|
||||
"geschichte_editor_unsaved_changes": "Du hast ungespeicherte Änderungen — wirklich verlassen?",
|
||||
"geschichte_editor_personen_heading": "Personen",
|
||||
"geschichte_editor_personen_hint": "Welche historischen Personen kommen in dieser Geschichte vor?",
|
||||
"geschichte_editor_dokumente_heading": "Dokumente",
|
||||
"geschichte_editor_dokumente_hint": "Welche Briefe oder Dokumente sind Teil dieser Geschichte?",
|
||||
"geschichte_documents_heading": "Briefe & Dokumente",
|
||||
"geschichte_documents_hint": "Welche Dokumente gehören zu dieser Geschichte?",
|
||||
"geschichte_documents_empty": "Noch keine Dokumente verknüpft. Suche unten nach einem Brief, um ihn dieser Geschichte hinzuzufügen.",
|
||||
"geschichte_documents_picker_label": "Dokument hinzufügen",
|
||||
"geschichte_documents_picker_placeholder": "Brief oder Dokument suchen…",
|
||||
"geschichte_documents_deleted_placeholder": "Dokument wurde gelöscht",
|
||||
"geschichte_documents_remove_label": "Dokument entfernen: {title}",
|
||||
"geschichte_documents_capacity": "Diese Geschichte hat bereits die maximale Anzahl von Dokumenten (100) erreicht.",
|
||||
"geschichte_documents_duplicate": "Dieses Dokument ist bereits mit der Geschichte verknüpft.",
|
||||
"geschichte_documents_added_announce": "Hinzugefügt: {title}",
|
||||
"geschichte_documents_removed_announce": "Entfernt: {title}",
|
||||
"geschichte_editor_search_person": "Person suchen…",
|
||||
"geschichte_editor_search_document": "Dokument suchen…",
|
||||
"geschichte_editor_toolbar_bold": "Fett (Strg+B)",
|
||||
@@ -1153,5 +1186,58 @@
|
||||
"themen_alle": "Alle Themen",
|
||||
"themen_leer": "Noch keine Themen vergeben.",
|
||||
"themen_weitere": "+ {count} weitere",
|
||||
"themen_dokumente": "{count} Dokumente"
|
||||
"themen_dokumente": "{count} Dokumente",
|
||||
"journey_badge_list": "REISE",
|
||||
"journey_badge_detail": "LESEREISE",
|
||||
"journey_selector_question": "Was möchtest du erstellen?",
|
||||
"journey_selector_story_title": "Geschichte",
|
||||
"journey_selector_story_desc": "Eine erzählte Geschichte mit Bildern und Text.",
|
||||
"journey_selector_journey_title": "Lesereise",
|
||||
"journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.",
|
||||
"journey_selector_next_btn": "Weiter",
|
||||
"journey_placeholder_back": "andere Auswahl",
|
||||
"journey_create_submit": "Lesereise erstellen",
|
||||
"journey_item_open_aria": "Brief vom {date} öffnen",
|
||||
"journey_item_open_aria_undated": "Brief öffnen",
|
||||
"journey_item_open": "Brief öffnen",
|
||||
"journey_item_meta_from_to": "von {sender} an {receiver}",
|
||||
"journey_empty_state": "Diese Lesereise ist noch leer.",
|
||||
"journey_interlude_aria_label": "Kuratorennotiz",
|
||||
"journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren.",
|
||||
"journey_add_document": "Brief hinzufügen",
|
||||
"journey_add_interlude": "Zwischentext hinzufügen",
|
||||
"journey_interlude_label": "Zwischentext",
|
||||
"journey_item_pending_remove": "wird entfernt…",
|
||||
"journey_publish_disabled_hint": "Titel und mindestens ein Eintrag erforderlich.",
|
||||
"journey_title_aria_label": "Titel der Lesereise",
|
||||
"journey_intro_aria_label": "Einleitung der Lesereise",
|
||||
"journey_note_add": "Notiz hinzufügen",
|
||||
"journey_note_remove": "Notiz entfernen",
|
||||
"journey_note_save_hint": "Wird gespeichert, wenn du das Feld verlässt.",
|
||||
"journey_intro_save_hint": "Wird mit 'Speichern' gesichert.",
|
||||
"journey_already_added": "Bereits enthalten",
|
||||
"journey_note_aria_label": "Kuratoren-Notiz für {title}",
|
||||
"journey_move_up": "'{title}' nach oben verschieben",
|
||||
"journey_move_down": "'{title}' nach unten verschieben",
|
||||
"journey_note_error": "Notiz konnte nicht gespeichert werden",
|
||||
"journey_item_moved": "Eintrag {position} von {total} — nach Position {newPosition} verschoben",
|
||||
"journey_remove_item_aria": "'{title}' entfernen",
|
||||
"journey_remove_confirm": "Wirklich entfernen?",
|
||||
"journey_remove_confirm_yes": "Bestätigen",
|
||||
"journey_remove_confirm_cancel": "Abbrechen",
|
||||
"journey_mutation_error_reload": "Aktion fehlgeschlagen – bitte Seite neu laden.",
|
||||
"journey_published_empty_warning": "Diese Reise wird ohne Einträge veröffentlicht bleiben.",
|
||||
"journey_intro_placeholder": "Einleitung (optional)",
|
||||
"journey_interlude_placeholder": "Zwischentext eingeben…",
|
||||
"journey_add_interlude_confirm": "Hinzufügen",
|
||||
"journey_edit_title_story": "Geschichte bearbeiten",
|
||||
"journey_edit_title_journey": "Lesereise bearbeiten",
|
||||
"journey_publish_disabled_title": "Titel und mindestens ein Eintrag erforderlich",
|
||||
"journey_save_hint_published": "Änderungen werden sofort für alle Leser sichtbar.",
|
||||
"error_journey_note_too_long": "Die Notiz ist zu lang (maximal 2000 Zeichen).",
|
||||
"error_geschichte_title_too_long": "Der Titel ist zu lang (maximal 255 Zeichen).",
|
||||
"error_geschichte_intro_too_long": "Die Einleitung ist zu lang (maximal 4000 Zeichen).",
|
||||
"person_unknown": "[Unbekannt]",
|
||||
"error_journey_document_already_added": "Dieser Brief ist bereits in der Lesereise enthalten.",
|
||||
"error_geschichte_type_immutable": "Der Typ einer Geschichte kann nach der Erstellung nicht mehr geändert werden."
|
||||
}
|
||||
|
||||
@@ -174,12 +174,18 @@
|
||||
"person_merge_warning": "Warning: This action cannot be undone.",
|
||||
"person_label_notes": "Notes",
|
||||
"person_placeholder_notes": "Biographical notes, remarks…",
|
||||
"person_label_birth_year": "Birth year",
|
||||
"person_label_death_year": "Death year",
|
||||
"person_label_birth_date": "Date of birth",
|
||||
"person_label_death_date": "Date of death",
|
||||
"person_label_birth_date_precision": "Precision",
|
||||
"person_label_death_date_precision": "Precision",
|
||||
"person_precision_hint": "How precisely is this date known?",
|
||||
"person_precision_day": "Exact date (day)",
|
||||
"person_precision_month": "Month known",
|
||||
"person_precision_year": "Year only",
|
||||
"person_date_placeholder_hint": "Leave empty if unknown",
|
||||
"person_label_generation": "Generation",
|
||||
"person_option_generation_unset": "(none)",
|
||||
"person_hint_generation": "Generation within the family (G 0 = oldest generation)",
|
||||
"person_placeholder_year": "e.g. 1923",
|
||||
"person_year_error": "Please enter a four-digit year",
|
||||
"person_years_error_order": "Birth year must be before death year",
|
||||
"person_docs_heading": "Sent documents",
|
||||
@@ -301,6 +307,8 @@
|
||||
"comp_multiselect_placeholder": "Type a name...",
|
||||
"comp_multiselect_remove": "Remove",
|
||||
"comp_multiselect_loading": "Searching...",
|
||||
"comp_typeahead_error": "Search failed. Please try again.",
|
||||
"comp_typeahead_no_results": "No matches",
|
||||
"comp_taginput_placeholder_create": "Add tags...",
|
||||
"comp_taginput_placeholder_filter": "Filter by tags...",
|
||||
"comp_taginput_remove": "Remove tag",
|
||||
@@ -641,6 +649,8 @@
|
||||
"error_alias_not_found": "The name alias was not found.",
|
||||
"error_invalid_person_type": "The specified person type is not valid.",
|
||||
"error_invalid_date_range": "The end date must not be before the start date.",
|
||||
"error_birth_after_death": "Birth date must be before death date. Tip: if only the death year is known and the birthday is late in the same year, enter the following year.",
|
||||
"error_invalid_date_precision": "Date and precision do not match.",
|
||||
"validation_last_name_required": "Last name is required.",
|
||||
"validation_first_name_required": "First name is required.",
|
||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||
@@ -1023,6 +1033,10 @@
|
||||
"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.",
|
||||
"journey_item_document_deleted": "[Document deleted]",
|
||||
"geschichten_index_title": "Stories",
|
||||
"geschichten_new_button": "New story",
|
||||
"geschichten_filter_all_pill": "All",
|
||||
@@ -1033,10 +1047,19 @@
|
||||
"geschichten_empty_for_person": "No stories found for {name}.",
|
||||
"geschichten_empty_for_persons": "No stories found for {names}.",
|
||||
"geschichten_empty_no_filter": "There are no published stories yet.",
|
||||
"geschichten_filter_document_chip": "Filtered by letter:",
|
||||
"geschichten_filter_remove_document_chip": "Remove letter {title} from filter",
|
||||
"geschichten_empty_for_document": "No stories reference this letter yet",
|
||||
"geschichten_published_heading": "Published",
|
||||
"geschichten_drafts_heading": "Drafts",
|
||||
"geschichten_draft_badge": "Draft",
|
||||
"geschichten_drafts_unfiltered_caption": "(all drafts)",
|
||||
"geschichten_back_to_index": "Back to stories",
|
||||
"geschichten_published_on": "published on {date}",
|
||||
"journey_compiled_on": "compiled 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",
|
||||
@@ -1044,6 +1067,7 @@
|
||||
"geschichten_card_show_all": "Show all",
|
||||
"geschichte_editor_title_placeholder": "Story title",
|
||||
"geschichte_editor_body_placeholder": "Write your story here…",
|
||||
"geschichte_sidebar_status": "Status",
|
||||
"geschichte_editor_status_draft": "DRAFT",
|
||||
"geschichte_editor_status_published": "PUBLISHED",
|
||||
"geschichte_editor_status_draft_hint": "Not yet visible to readers.",
|
||||
@@ -1058,8 +1082,17 @@
|
||||
"geschichte_editor_unsaved_changes": "You have unsaved changes — leave anyway?",
|
||||
"geschichte_editor_personen_heading": "People",
|
||||
"geschichte_editor_personen_hint": "Which historical persons appear in this story?",
|
||||
"geschichte_editor_dokumente_heading": "Documents",
|
||||
"geschichte_editor_dokumente_hint": "Which letters or documents are part of this story?",
|
||||
"geschichte_documents_heading": "Letters & documents",
|
||||
"geschichte_documents_hint": "Which documents belong to this story?",
|
||||
"geschichte_documents_empty": "No documents linked yet. Search below for a letter to add it to this story.",
|
||||
"geschichte_documents_picker_label": "Add document",
|
||||
"geschichte_documents_picker_placeholder": "Search for a letter or document…",
|
||||
"geschichte_documents_deleted_placeholder": "Document was deleted",
|
||||
"geschichte_documents_remove_label": "Remove document: {title}",
|
||||
"geschichte_documents_capacity": "This story has already reached the maximum of 100 documents.",
|
||||
"geschichte_documents_duplicate": "This document is already linked to the story.",
|
||||
"geschichte_documents_added_announce": "Added: {title}",
|
||||
"geschichte_documents_removed_announce": "Removed: {title}",
|
||||
"geschichte_editor_search_person": "Search person…",
|
||||
"geschichte_editor_search_document": "Search document…",
|
||||
"geschichte_editor_toolbar_bold": "Bold (Ctrl+B)",
|
||||
@@ -1153,5 +1186,58 @@
|
||||
"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_create_submit": "Create reading journey",
|
||||
"journey_item_open_aria": "Open letter from {date}",
|
||||
"journey_item_open_aria_undated": "Open letter",
|
||||
"journey_item_open": "Open letter",
|
||||
"journey_item_meta_from_to": "from {sender} to {receiver}",
|
||||
"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.",
|
||||
"journey_add_document": "Add letter",
|
||||
"journey_add_interlude": "Add interlude",
|
||||
"journey_interlude_label": "Interlude",
|
||||
"journey_item_pending_remove": "removing…",
|
||||
"journey_publish_disabled_hint": "A title and at least one entry are required.",
|
||||
"journey_title_aria_label": "Title of the reading journey",
|
||||
"journey_intro_aria_label": "Introduction of the reading journey",
|
||||
"journey_note_add": "Add note",
|
||||
"journey_note_remove": "Remove note",
|
||||
"journey_note_save_hint": "Saved when you leave the field.",
|
||||
"journey_intro_save_hint": "Saved when you click 'Save'.",
|
||||
"journey_already_added": "Already included",
|
||||
"journey_note_aria_label": "Curator note for {title}",
|
||||
"journey_move_up": "Move '{title}' up",
|
||||
"journey_move_down": "Move '{title}' down",
|
||||
"journey_note_error": "Could not save note",
|
||||
"journey_item_moved": "Entry {position} of {total} — moved to position {newPosition}",
|
||||
"journey_remove_item_aria": "Remove '{title}'",
|
||||
"journey_remove_confirm": "Really remove?",
|
||||
"journey_remove_confirm_yes": "Confirm",
|
||||
"journey_remove_confirm_cancel": "Cancel",
|
||||
"journey_mutation_error_reload": "Action failed – please reload the page.",
|
||||
"journey_published_empty_warning": "This journey will remain published without any entries.",
|
||||
"journey_intro_placeholder": "Introduction (optional)",
|
||||
"journey_interlude_placeholder": "Enter interlude text…",
|
||||
"journey_add_interlude_confirm": "Add",
|
||||
"journey_edit_title_story": "Edit story",
|
||||
"journey_edit_title_journey": "Edit reading journey",
|
||||
"journey_publish_disabled_title": "Title and at least one entry required",
|
||||
"journey_save_hint_published": "Changes will be immediately visible to all readers.",
|
||||
"error_journey_note_too_long": "The note is too long (maximum 2000 characters).",
|
||||
"error_geschichte_title_too_long": "The title is too long (maximum 255 characters).",
|
||||
"error_geschichte_intro_too_long": "The introduction is too long (maximum 4000 characters).",
|
||||
"person_unknown": "[Unknown]",
|
||||
"error_journey_document_already_added": "This letter is already included in the reading journey.",
|
||||
"error_geschichte_type_immutable": "The type of a story cannot be changed after creation."
|
||||
}
|
||||
|
||||
@@ -174,12 +174,18 @@
|
||||
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
|
||||
"person_label_notes": "Notas",
|
||||
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
||||
"person_label_birth_year": "Año de nacimiento",
|
||||
"person_label_death_year": "Año de fallecimiento",
|
||||
"person_label_birth_date": "Fecha de nacimiento",
|
||||
"person_label_death_date": "Fecha de defunción",
|
||||
"person_label_birth_date_precision": "Precisión",
|
||||
"person_label_death_date_precision": "Precisión",
|
||||
"person_precision_hint": "¿Con qué precisión se conoce esta fecha?",
|
||||
"person_precision_day": "Fecha exacta (día)",
|
||||
"person_precision_month": "Mes conocido",
|
||||
"person_precision_year": "Solo año",
|
||||
"person_date_placeholder_hint": "Dejar vacío si es desconocido",
|
||||
"person_label_generation": "Generación",
|
||||
"person_option_generation_unset": "(ninguna)",
|
||||
"person_hint_generation": "Generación dentro de la familia (G 0 = generación más antigua)",
|
||||
"person_placeholder_year": "p.ej. 1923",
|
||||
"person_year_error": "Introduzca un año de cuatro dígitos",
|
||||
"person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento",
|
||||
"person_docs_heading": "Documentos enviados",
|
||||
@@ -301,6 +307,8 @@
|
||||
"comp_multiselect_placeholder": "Escriba un nombre...",
|
||||
"comp_multiselect_remove": "Eliminar",
|
||||
"comp_multiselect_loading": "Buscando...",
|
||||
"comp_typeahead_error": "La búsqueda falló. Inténtelo de nuevo.",
|
||||
"comp_typeahead_no_results": "Sin resultados",
|
||||
"comp_taginput_placeholder_create": "Añadir etiquetas...",
|
||||
"comp_taginput_placeholder_filter": "Filtrar por etiquetas...",
|
||||
"comp_taginput_remove": "Eliminar etiqueta",
|
||||
@@ -641,6 +649,8 @@
|
||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||
"error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.",
|
||||
"error_birth_after_death": "La fecha de nacimiento debe ser anterior a la de defunción.",
|
||||
"error_invalid_date_precision": "La fecha y la precisión no coinciden.",
|
||||
"validation_last_name_required": "El apellido es obligatorio.",
|
||||
"validation_first_name_required": "El nombre es obligatorio.",
|
||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||
@@ -1023,6 +1033,10 @@
|
||||
"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.",
|
||||
"journey_item_document_deleted": "[Documento eliminado]",
|
||||
"geschichten_index_title": "Historias",
|
||||
"geschichten_new_button": "Nueva historia",
|
||||
"geschichten_filter_all_pill": "Todas",
|
||||
@@ -1033,10 +1047,19 @@
|
||||
"geschichten_empty_for_person": "No hay historias para {name}.",
|
||||
"geschichten_empty_for_persons": "No hay historias para {names}.",
|
||||
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
|
||||
"geschichten_filter_document_chip": "Filtrado por carta:",
|
||||
"geschichten_filter_remove_document_chip": "Quitar la carta {title} del filtro",
|
||||
"geschichten_empty_for_document": "Aún no hay historias sobre esta carta",
|
||||
"geschichten_published_heading": "Publicadas",
|
||||
"geschichten_drafts_heading": "Borradores",
|
||||
"geschichten_draft_badge": "Borrador",
|
||||
"geschichten_drafts_unfiltered_caption": "(todos los borradores)",
|
||||
"geschichten_back_to_index": "Volver a Historias",
|
||||
"geschichten_published_on": "publicada el {date}",
|
||||
"journey_compiled_on": "recopilada 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",
|
||||
@@ -1044,6 +1067,7 @@
|
||||
"geschichten_card_show_all": "Mostrar todas",
|
||||
"geschichte_editor_title_placeholder": "Título de la historia",
|
||||
"geschichte_editor_body_placeholder": "Escribe tu historia aquí…",
|
||||
"geschichte_sidebar_status": "Estado",
|
||||
"geschichte_editor_status_draft": "BORRADOR",
|
||||
"geschichte_editor_status_published": "PUBLICADA",
|
||||
"geschichte_editor_status_draft_hint": "Aún no visible para lectores.",
|
||||
@@ -1058,8 +1082,17 @@
|
||||
"geschichte_editor_unsaved_changes": "Tienes cambios no guardados — ¿salir igualmente?",
|
||||
"geschichte_editor_personen_heading": "Personas",
|
||||
"geschichte_editor_personen_hint": "¿Qué personas históricas aparecen en esta historia?",
|
||||
"geschichte_editor_dokumente_heading": "Documentos",
|
||||
"geschichte_editor_dokumente_hint": "¿Qué cartas o documentos forman parte de esta historia?",
|
||||
"geschichte_documents_heading": "Cartas y documentos",
|
||||
"geschichte_documents_hint": "¿Qué documentos pertenecen a esta historia?",
|
||||
"geschichte_documents_empty": "Aún no hay documentos vinculados. Busca abajo una carta para añadirla a esta historia.",
|
||||
"geschichte_documents_picker_label": "Añadir documento",
|
||||
"geschichte_documents_picker_placeholder": "Buscar una carta o documento…",
|
||||
"geschichte_documents_deleted_placeholder": "El documento fue eliminado",
|
||||
"geschichte_documents_remove_label": "Quitar documento: {title}",
|
||||
"geschichte_documents_capacity": "Esta historia ya ha alcanzado el número máximo de documentos (100).",
|
||||
"geschichte_documents_duplicate": "Este documento ya está vinculado a la historia.",
|
||||
"geschichte_documents_added_announce": "Añadido: {title}",
|
||||
"geschichte_documents_removed_announce": "Quitado: {title}",
|
||||
"geschichte_editor_search_person": "Buscar persona…",
|
||||
"geschichte_editor_search_document": "Buscar documento…",
|
||||
"geschichte_editor_toolbar_bold": "Negrita (Ctrl+B)",
|
||||
@@ -1153,5 +1186,58 @@
|
||||
"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_create_submit": "Crear viaje de lectura",
|
||||
"journey_item_open_aria": "Abrir carta del {date}",
|
||||
"journey_item_open_aria_undated": "Abrir carta",
|
||||
"journey_item_open": "Abrir carta",
|
||||
"journey_item_meta_from_to": "de {sender} a {receiver}",
|
||||
"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.",
|
||||
"journey_add_document": "Añadir carta",
|
||||
"journey_add_interlude": "Añadir interludio",
|
||||
"journey_interlude_label": "Interludio",
|
||||
"journey_item_pending_remove": "eliminando…",
|
||||
"journey_publish_disabled_hint": "Se requieren un título y al menos una entrada.",
|
||||
"journey_title_aria_label": "Título del viaje de lectura",
|
||||
"journey_intro_aria_label": "Introducción del viaje de lectura",
|
||||
"journey_note_add": "Añadir nota",
|
||||
"journey_note_remove": "Eliminar nota",
|
||||
"journey_note_save_hint": "Se guarda al salir del campo.",
|
||||
"journey_intro_save_hint": "Se guarda al hacer clic en 'Guardar'.",
|
||||
"journey_already_added": "Ya incluido",
|
||||
"journey_note_aria_label": "Nota del curador para {title}",
|
||||
"journey_move_up": "Subir '{title}'",
|
||||
"journey_move_down": "Bajar '{title}'",
|
||||
"journey_note_error": "No se pudo guardar la nota",
|
||||
"journey_item_moved": "Entrada {position} de {total} — movida a la posición {newPosition}",
|
||||
"journey_remove_item_aria": "Eliminar '{title}'",
|
||||
"journey_remove_confirm": "¿Realmente eliminar?",
|
||||
"journey_remove_confirm_yes": "Confirmar",
|
||||
"journey_remove_confirm_cancel": "Cancelar",
|
||||
"journey_mutation_error_reload": "Acción fallida – por favor recarga la página.",
|
||||
"journey_published_empty_warning": "Este viaje permanecerá publicado sin entradas.",
|
||||
"journey_intro_placeholder": "Introducción (opcional)",
|
||||
"journey_interlude_placeholder": "Escribe el texto del interludio…",
|
||||
"journey_add_interlude_confirm": "Añadir",
|
||||
"journey_edit_title_story": "Editar historia",
|
||||
"journey_edit_title_journey": "Editar viaje de lectura",
|
||||
"journey_publish_disabled_title": "Se requiere título y al menos una entrada",
|
||||
"journey_save_hint_published": "Los cambios serán visibles inmediatamente para todos los lectores.",
|
||||
"error_journey_note_too_long": "La nota es demasiado larga (máximo 2000 caracteres).",
|
||||
"error_geschichte_title_too_long": "El título es demasiado largo (máximo 255 caracteres).",
|
||||
"error_geschichte_intro_too_long": "La introducción es demasiado larga (máximo 4000 caracteres).",
|
||||
"person_unknown": "[Desconocido]",
|
||||
"error_journey_document_already_added": "Esta carta ya está incluida en el viaje de lectura.",
|
||||
"error_geschichte_type_immutable": "El tipo de una historia no se puede cambiar después de su creación."
|
||||
}
|
||||
|
||||
226
frontend/package-lock.json
generated
226
frontend/package-lock.json
generated
@@ -558,9 +558,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -574,9 +574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -590,9 +590,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -606,9 +606,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -622,9 +622,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -638,9 +638,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -654,9 +654,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -670,9 +670,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -686,9 +686,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -702,9 +702,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -718,9 +718,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -734,9 +734,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -750,9 +750,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -766,9 +766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -782,9 +782,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -798,9 +798,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -814,9 +814,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -830,9 +830,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -846,9 +846,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -862,9 +862,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -878,9 +878,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -894,9 +894,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -910,9 +910,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -926,9 +926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -942,9 +942,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -958,9 +958,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5531,12 +5531,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
@@ -5799,9 +5803,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -5811,32 +5815,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
|
||||
@@ -73,5 +73,9 @@
|
||||
"vite-plugin-static-copy": "^4.1.0",
|
||||
"vitest": "^4.0.10",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.1",
|
||||
"cookie": ">=0.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||
|
||||
/**
|
||||
* Exactly the fields this picker reads — id for selection/dedup, the rest for
|
||||
* the honest date label. A full `Document` and a `DocumentListItem` are both
|
||||
* structurally assignable, so the search results need no cast.
|
||||
*/
|
||||
type DocumentOption = Pick<
|
||||
DocumentListItem,
|
||||
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||
>;
|
||||
import {
|
||||
createDocumentTypeahead,
|
||||
formatDocumentOption,
|
||||
type DocumentOption
|
||||
} from './documentTypeahead';
|
||||
|
||||
interface Props {
|
||||
selectedDocuments?: DocumentOption[];
|
||||
@@ -30,13 +20,16 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
let searchTerm = $state('');
|
||||
let results: DocumentOption[] = $state([]);
|
||||
let showDropdown = $state(false);
|
||||
let loading = $state(false);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
let inputEl: HTMLInputElement;
|
||||
let dropdownStyle = $state('');
|
||||
|
||||
const picker = createDocumentTypeahead();
|
||||
|
||||
// Filter out already-selected documents from typeahead results.
|
||||
const filteredResults = $derived(
|
||||
picker.results.filter((d) => !selectedDocuments.some((s) => s.id === d.id))
|
||||
);
|
||||
|
||||
function updateDropdownPosition() {
|
||||
if (!inputEl) return;
|
||||
const rect = inputEl.getBoundingClientRect();
|
||||
@@ -44,57 +37,22 @@ function updateDropdownPosition() {
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
showDropdown = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
if (searchTerm.length < 1) {
|
||||
results = [];
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
|
||||
if (res.ok) {
|
||||
const body: { items: DocumentListItem[] } = await res.json();
|
||||
const docs: DocumentOption[] = body.items.map((it) => ({
|
||||
id: it.id,
|
||||
title: it.title,
|
||||
documentDate: it.documentDate,
|
||||
metaDatePrecision: it.metaDatePrecision,
|
||||
metaDateEnd: it.metaDateEnd
|
||||
}));
|
||||
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
|
||||
}
|
||||
} catch {
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, 300);
|
||||
if (searchTerm.trim().length >= 1) {
|
||||
picker.setQuery(searchTerm);
|
||||
} else {
|
||||
picker.close();
|
||||
}
|
||||
}
|
||||
|
||||
function selectDocument(doc: DocumentOption) {
|
||||
selectedDocuments = [...selectedDocuments, doc];
|
||||
searchTerm = '';
|
||||
showDropdown = false;
|
||||
results = [];
|
||||
picker.close();
|
||||
}
|
||||
|
||||
function removeDocument(id: string | undefined) {
|
||||
selectedDocuments = selectedDocuments.filter((d) => d.id !== id);
|
||||
}
|
||||
|
||||
function formatDocLabel(doc: DocumentOption): string {
|
||||
if (!doc.documentDate) return doc.title;
|
||||
const label = formatDocumentDate(
|
||||
doc.documentDate,
|
||||
doc.metaDatePrecision as DatePrecision,
|
||||
doc.metaDateEnd,
|
||||
null,
|
||||
getLocale()
|
||||
);
|
||||
return `${doc.title} · ${label}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
@@ -103,7 +61,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
<input type="hidden" name={hiddenInputName} value={doc.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => picker.close()}>
|
||||
<div
|
||||
class="flex min-h-[38px] flex-wrap gap-2 rounded border border-line bg-surface p-2 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring focus-within:outline-none"
|
||||
>
|
||||
@@ -111,7 +69,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink"
|
||||
>
|
||||
{formatDocLabel(doc)}
|
||||
{formatDocumentOption(doc)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeDocument(doc.id)}
|
||||
@@ -136,24 +94,23 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
autocomplete="off"
|
||||
bind:value={searchTerm}
|
||||
oninput={handleInput}
|
||||
onfocus={() => {
|
||||
updateDropdownPosition();
|
||||
showDropdown = true;
|
||||
}}
|
||||
onfocus={() => updateDropdownPosition()}
|
||||
placeholder={placeholder}
|
||||
class="min-w-[120px] flex-1 border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
{#if picker.isOpen && (filteredResults.length > 0 || picker.loading || picker.error)}
|
||||
<div
|
||||
style={dropdownStyle}
|
||||
class="ring-opacity-5 z-50 max-h-60 overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black sm:text-sm"
|
||||
>
|
||||
{#if loading}
|
||||
{#if picker.loading}
|
||||
<div class="p-2 text-sm text-ink-2">{m.comp_multiselect_loading()}</div>
|
||||
{:else if picker.error}
|
||||
<div role="alert" class="p-2 text-sm text-danger">{m.comp_typeahead_error()}</div>
|
||||
{:else}
|
||||
{#each results as doc (doc.id)}
|
||||
{#each filteredResults as doc (doc.id)}
|
||||
<div
|
||||
class="cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-muted"
|
||||
onclick={() => selectDocument(doc)}
|
||||
@@ -161,7 +118,7 @@ function formatDocLabel(doc: DocumentOption): string {
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{formatDocLabel(doc)}
|
||||
{formatDocumentOption(doc)}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import DocumentMultiSelect from './DocumentMultiSelect.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
|
||||
|
||||
@@ -124,6 +125,28 @@ describe('DocumentMultiSelect — search and select', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentMultiSelect — search failure', () => {
|
||||
it('shows an error row when the search request fails instead of looking like "no results"', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: vi.fn().mockResolvedValue({ code: 'INTERNAL_ERROR' })
|
||||
})
|
||||
);
|
||||
|
||||
render(DocumentMultiSelect);
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Dokument suchen…'), 'Eug');
|
||||
await waitForDebounce();
|
||||
|
||||
const alert = page.getByRole('alert');
|
||||
await expect.element(alert).toBeInTheDocument();
|
||||
await expect.element(alert).toHaveTextContent(m.comp_typeahead_error());
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocumentMultiSelect — remove', () => {
|
||||
it('removes a chip when its × button is clicked', async () => {
|
||||
render(DocumentMultiSelect, {
|
||||
|
||||
155
frontend/src/lib/document/DocumentPickerDropdown.svelte
Normal file
155
frontend/src/lib/document/DocumentPickerDropdown.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import {
|
||||
createDocumentTypeahead,
|
||||
formatDocumentOption,
|
||||
type DocumentOption
|
||||
} from './documentTypeahead';
|
||||
|
||||
interface Props {
|
||||
alreadyAddedIds?: Set<string>;
|
||||
placeholder?: string;
|
||||
/** Set when a visible <label for> is wired externally — replaces the aria-label fallback. */
|
||||
inputId?: string;
|
||||
onSelect: (doc: DocumentOption) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
alreadyAddedIds = new Set(),
|
||||
placeholder = m.journey_add_document(),
|
||||
inputId = undefined,
|
||||
onSelect
|
||||
}: Props = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const listboxId = `doc-picker-listbox-${uid}`;
|
||||
const resolvedInputId = $derived(inputId ?? `doc-picker-input-${uid}`);
|
||||
|
||||
const picker = createDocumentTypeahead();
|
||||
|
||||
let inputValue = $state('');
|
||||
|
||||
const activeOptionId = $derived(
|
||||
picker.isOpen && picker.activeIndex >= 0 && picker.results[picker.activeIndex]
|
||||
? `${listboxId}-option-${picker.activeIndex}`
|
||||
: undefined
|
||||
);
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const q = (e.currentTarget as HTMLInputElement).value;
|
||||
inputValue = q;
|
||||
picker.setActiveIndex(-1);
|
||||
if (q.trim().length >= 1) {
|
||||
picker.setQuery(q);
|
||||
} else {
|
||||
picker.close();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(doc: DocumentOption) {
|
||||
if (alreadyAddedIds.has(doc.id!)) return;
|
||||
inputValue = '';
|
||||
picker.close();
|
||||
onSelect(doc);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!picker.isOpen) return;
|
||||
|
||||
const results = picker.results;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (results.length > 0) {
|
||||
picker.setActiveIndex((picker.activeIndex + 1) % results.length);
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (results.length > 0) {
|
||||
picker.setActiveIndex((picker.activeIndex - 1 + results.length) % results.length);
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const active = results[picker.activeIndex];
|
||||
// handleSelect is a no-op for already-added (aria-disabled) options.
|
||||
if (active) handleSelect(active);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
picker.close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div use:clickOutside onclickoutside={() => picker.close()} class="relative">
|
||||
<input
|
||||
type="text"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
id={resolvedInputId}
|
||||
aria-label={inputId ? undefined : placeholder}
|
||||
aria-expanded={picker.isOpen}
|
||||
aria-controls={picker.isOpen && !picker.loading && !picker.error && picker.results.length > 0
|
||||
? listboxId
|
||||
: undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeOptionId}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
|
||||
{#if picker.isOpen}
|
||||
{#if picker.loading}
|
||||
<div
|
||||
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||
>
|
||||
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_multiselect_loading()}</p>
|
||||
</div>
|
||||
{:else if picker.error}
|
||||
<div
|
||||
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||
>
|
||||
<p role="alert" class="px-3 py-2 text-danger">{m.comp_typeahead_error()}</p>
|
||||
</div>
|
||||
{:else if picker.results.length === 0}
|
||||
<div
|
||||
class="ring-opacity-5 absolute z-50 mt-1 w-full rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||
>
|
||||
<p role="status" class="px-3 py-2 text-ink-2">{m.comp_typeahead_no_results()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
class="ring-opacity-5 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-sm shadow-lg ring-1 ring-black"
|
||||
>
|
||||
{#each picker.results as doc, i (doc.id)}
|
||||
{@const disabled = alreadyAddedIds.has(doc.id!)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<li
|
||||
id={`${listboxId}-option-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === picker.activeIndex}
|
||||
aria-disabled={disabled}
|
||||
onclick={() => handleSelect(doc)}
|
||||
class={[
|
||||
'px-3 py-2 text-ink select-none',
|
||||
i === picker.activeIndex ? 'bg-muted' : '',
|
||||
disabled
|
||||
? 'cursor-default opacity-50'
|
||||
: 'cursor-pointer hover:bg-muted'
|
||||
].join(' ')}
|
||||
>
|
||||
{formatDocumentOption(doc)}
|
||||
{#if disabled}
|
||||
<span class="sr-only">{m.journey_already_added()}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user