diff --git a/CLAUDE.md b/CLAUDE.md index 8364399a..bba08400 100644 --- a/CLAUDE.md +++ b/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 @@ -105,13 +106,15 @@ backend/src/main/java/org/raddatz/familienarchiv/ ### Domain Model -| Entity | Table | Key relationships | -| ----------- | ------------- | ------------------------------------------------------------------------------------- | -| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) | -| `Person` | `persons` | Referenced by documents as sender/receiver | -| `Tag` | `tag` | ManyToMany with documents via `document_tags` | -| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) | -| `UserGroup` | `user_groups` | Has a `Set 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 permissions` | +| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) | +| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` | **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index b96d242a..38d5b08b 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java index 62f04874..7b63ec27 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/audit/AuditKind.java @@ -50,10 +50,25 @@ public enum AuditKind { ADMIN_FORCE_LOGOUT, /** Payload: {@code {"ip": "1.2.3.4", "email": "addr"}} — password NEVER included */ - LOGIN_RATE_LIMITED; + LOGIN_RATE_LIMITED, + + // --- Reading Journeys (Lesereisen) --- + + /** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null (journey-scoped, not document-scoped) */ + JOURNEY_ITEM_ADDED, + + /** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */ + JOURNEY_ITEM_REMOVED, + + /** Payload: {@code {"geschichteId": "uuid", "itemId": "uuid"}} — documentId is null */ + JOURNEY_ITEM_NOTE_UPDATED, + + /** Payload: {@code {"geschichteId": "uuid", "itemCount": 3}} — documentId is null; rolled up in chronik */ + JOURNEY_ITEMS_REORDERED; public static final Set 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 ); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index 4f69922c..d4715f40 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -1006,6 +1006,28 @@ public class DocumentService { return doc; } + /** + * Lightweight summary lookup for internal use (e.g. journey item append validation). + * + *

Security contract — read before calling: + *

    + *
  1. 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.
  2. + *
  3. 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.
  4. + *
+ * 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 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 3eb5287d..1eaf8a2b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -122,6 +122,14 @@ public enum ErrorCode { // --- Geschichten (Stories) --- /** A Geschichte (story) with the given ID does not exist, or is a DRAFT and the caller lacks BLOG_WRITE. 404 */ GESCHICHTE_NOT_FOUND, + /** A JourneyItem with the given ID does not exist, or belongs to a different journey (IDOR). 404 */ + JOURNEY_ITEM_NOT_FOUND, + /** A position uniqueness conflict occurred on the journey_items table — concurrent append or reorder. 409 */ + JOURNEY_ITEM_POSITION_CONFLICT, + /** The journey already has the maximum allowed number of items (100). 400 */ + JOURNEY_AT_CAPACITY, + /** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */ + GESCHICHTE_TYPE_MISMATCH, // --- Tags --- /** A tag with the given ID does not exist. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java index 686ef457..c56cc576 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java @@ -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")); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java index a50679f2..e6188cdc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/Geschichte.java @@ -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 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 documents = new HashSet<>(); + private List items = new ArrayList<>(); @CreationTimestamp @Column(updatable = false) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java index 1da96278..4eed4e16 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java @@ -1,12 +1,14 @@ package org.raddatz.familienarchiv.geschichte; import lombok.RequiredArgsConstructor; -import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO; -import org.raddatz.familienarchiv.geschichte.Geschichte; -import org.raddatz.familienarchiv.geschichte.GeschichteStatus; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemCreateDTO; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemUpdateDTO; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyReorderDTO; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; -import org.raddatz.familienarchiv.geschichte.GeschichteService; +import io.swagger.v3.oas.annotations.Operation; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -28,23 +31,22 @@ import java.util.UUID; public class GeschichteController { private final GeschichteService geschichteService; + private final JourneyItemService journeyItemService; @GetMapping - public List list( + public List list( @RequestParam(required = false) GeschichteStatus status, @RequestParam(name = "personId", required = false) List personIds, - @RequestParam(required = false) UUID documentId, @RequestParam(required = false, defaultValue = "50") int limit) { return geschichteService.list( status, personIds == null ? List.of() : personIds, - documentId, limit); } @GetMapping("/{id}") - public Geschichte getById(@PathVariable UUID id) { - return geschichteService.getById(id); + public GeschichteView getById(@PathVariable UUID id) { + return geschichteService.getView(id); } @PostMapping @@ -66,4 +68,45 @@ public class GeschichteController { geschichteService.delete(id); return ResponseEntity.noContent().build(); } + + // ─── JourneyItem CRUD ──────────────────────────────────────────────────── + + @PostMapping("/{id}/items") + @RequirePermission(Permission.BLOG_WRITE) + public ResponseEntity 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 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 reorderItems( + @PathVariable UUID id, + @RequestBody JourneyReorderDTO dto) { + return journeyItemService.reorder(id, dto); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryService.java new file mode 100644 index 00000000..cde834bd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryService.java @@ -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 findById(UUID id) { + return geschichteRepository.findById(id); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java index b42a47a5..f0947218 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteRepository.java @@ -1,12 +1,43 @@ package org.raddatz.familienarchiv.geschichte; -import org.raddatz.familienarchiv.geschichte.Geschichte; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; import java.util.UUID; @Repository public interface GeschichteRepository extends JpaRepository, JpaSpecificationExecutor { + + /** + * Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false). + * + *

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. + * + *

Person filter: personCount=0 disables the filter. When personCount>0, the story must + * be associated with ALL person ids in personIds (AND-semantics via counting subquery). + * Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL. + */ + @Query(""" + SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type, + g.author AS author, g.publishedAt AS publishedAt, g.body AS body + FROM Geschichte g + WHERE g.status = :effectiveStatus + AND (:authorId IS NULL OR g.author.id = :authorId) + AND (:personCount = 0 OR + (SELECT COUNT(DISTINCT p.id) + FROM Geschichte g2 JOIN g2.persons p + WHERE g2.id = g.id AND p.id IN :personIds) = :personCount) + ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC + """) + List findSummaries( + @Param("effectiveStatus") GeschichteStatus effectiveStatus, + @Param("authorId") UUID authorId, + @Param("personIds") Collection personIds, + @Param("personCount") long personCount); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index 53443cf4..2a0d8819 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -4,28 +4,23 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; -import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView; import org.raddatz.familienarchiv.user.AppUser; -import org.raddatz.familienarchiv.document.Document; -import org.raddatz.familienarchiv.geschichte.Geschichte; -import org.raddatz.familienarchiv.geschichte.GeschichteStatus; import org.raddatz.familienarchiv.person.Person; -import org.raddatz.familienarchiv.geschichte.GeschichteRepository; -import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.user.UserService; -import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.domain.Specification; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Collection; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; @@ -41,6 +36,7 @@ public class GeschichteService { private final PersonService personService; private final DocumentService documentService; private final UserService userService; + private final JourneyItemService journeyItemService; /** * Allow-list policy for Geschichte body HTML. Tiptap on the writer side @@ -60,6 +56,7 @@ public class GeschichteService { return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED)); } + @Transactional(readOnly = true) public Geschichte getById(UUID id) { Geschichte g = geschichteRepository.findById(id) .orElseThrow(() -> DomainException.notFound( @@ -72,24 +69,57 @@ public class GeschichteService { return g; } + @Transactional(readOnly = true) + public GeschichteView getView(UUID id) { + Geschichte g = getById(id); + List items = journeyItemService.getItems(id); + return toView(g, items); + } + + GeschichteView toView(Geschichte g, List 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 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}. + * + *

Returns a {@link GeschichteSummary} projection — never carries items, preventing + * LazyInitializationException on the non-transactional list path. */ - public List list(GeschichteStatus status, List personIds, UUID documentId, int limit) { + public List list(GeschichteStatus status, List personIds, int limit) { GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null; - Specification spec = Specification.allOf( - GeschichteSpecifications.hasStatus(effective), - GeschichteSpecifications.hasAuthor(authorId), - GeschichteSpecifications.hasAllPersons(personIds), - GeschichteSpecifications.hasDocument(documentId), - GeschichteSpecifications.orderByDisplayDateDesc() - ); - return geschichteRepository.findAll(spec, Sort.unsorted()) + + // When personIds is empty, personCount=0 short-circuits the IN() predicate. + // Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped. + Collection safePersonIds = (personIds == null || personIds.isEmpty()) + ? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000")) + : personIds; + long personCount = (personIds == null) ? 0 : personIds.size(); + + return geschichteRepository + .findSummaries(effective, authorId, safePersonIds, personCount) .stream() .limit(safeLimit) .toList(); @@ -106,7 +136,6 @@ public class GeschichteService { .status(GeschichteStatus.DRAFT) .author(currentUser()) .persons(resolvePersons(dto.getPersonIds())) - .documents(resolveDocuments(dto.getDocumentIds())) .build(); if (dto.getStatus() == GeschichteStatus.PUBLISHED) { g.setStatus(GeschichteStatus.PUBLISHED); @@ -130,9 +159,6 @@ public class GeschichteService { if (dto.getPersonIds() != null) { g.setPersons(resolvePersons(dto.getPersonIds())); } - if (dto.getDocumentIds() != null) { - g.setDocuments(resolveDocuments(dto.getDocumentIds())); - } if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) { applyStatusTransition(g, dto.getStatus()); } @@ -176,15 +202,6 @@ public class GeschichteService { return new LinkedHashSet<>(personService.getAllById(ids)); } - private Set resolveDocuments(List ids) { - if (ids == null || ids.isEmpty()) return new HashSet<>(); - Set 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()) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java index 42797ffe..b8ff3603 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSpecifications.java @@ -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 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 documentSubquery( - Root root, CriteriaQuery query, CriteriaBuilder cb, UUID documentId) { - Subquery sub = query.subquery(UUID.class); - Root subRoot = sub.from(Geschichte.class); - Join 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; - } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java new file mode 100644 index 00000000..ae9d0de7 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteSummary.java @@ -0,0 +1,42 @@ +package org.raddatz.familienarchiv.geschichte; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * List-projection for the /api/geschichten grid. Never carries items — avoids + * LazyInitializationException (open-in-view: false) and prevents Cartesian joins. + * Mirrors the PersonSummaryDTO precedent. + * + *

Field set: exactly what the live grid card renders (title, author byline, body excerpt, + * publishedAt, status, type). Does NOT carry items or persons. + */ +public interface GeschichteSummary { + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + UUID getId(); + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String getTitle(); + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + GeschichteStatus getStatus(); + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + GeschichteType getType(); + + /** Nested closed projection — exposes only the fields the grid card needs. */ + AuthorSummary getAuthor(); + + LocalDateTime getPublishedAt(); + + String getBody(); + + interface AuthorSummary { + String getFirstName(); + String getLastName(); + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + String getEmail(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java new file mode 100644 index 00000000..57b7fb27 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteType.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.geschichte; + +public enum GeschichteType { + STORY, + JOURNEY +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java index bd05d568..969ca6dd 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteUpdateDTO.java @@ -1,7 +1,6 @@ package org.raddatz.familienarchiv.geschichte; import lombok.Data; -import org.raddatz.familienarchiv.geschichte.GeschichteStatus; import java.util.List; import java.util.UUID; @@ -17,5 +16,4 @@ public class GeschichteUpdateDTO { private String body; private GeschichteStatus status; private List personIds; - private List documentIds; } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteView.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteView.java new file mode 100644 index 00000000..814e4038 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteView.java @@ -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 persons, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List 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 + ) {} +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/PersonNameFormatter.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/PersonNameFormatter.java new file mode 100644 index 00000000..91c63231 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/PersonNameFormatter.java @@ -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; + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/DocumentSummary.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/DocumentSummary.java new file mode 100644 index 00000000..17ba1848 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/DocumentSummary.java @@ -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 +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java new file mode 100644 index 00000000..173423d9 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItem.java @@ -0,0 +1,51 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.geschichte.Geschichte; + +import java.util.UUID; + +@Entity +@Table(name = "journey_items") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class JourneyItem { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "geschichte_id", nullable = false) + @JsonIgnore + private Geschichte geschichte; + + // Sort key; gaps fine. Duplicate positions within a journey yield undefined relative order + // — the editor is responsible for keeping them distinct. + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int position; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "document_id") + @JsonIgnore + private Document document; + + // CWE-79 tripwire: plain text — store verbatim, no sanitization. Any HTML/feed/PDF/email + // renderer MUST escape this; only Svelte {note} is auto-safe. + @Column(columnDefinition = "TEXT") + private String note; + + // JPA uses field access — this getter is not persisted. Jackson serializes it as documentId. + // Exposing only the UUID prevents circular references and large nested payloads. + public UUID getDocumentId() { + return document != null ? document.getId() : null; + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemCreateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemCreateDTO.java new file mode 100644 index 00000000..9a7c420d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemCreateDTO.java @@ -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; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java new file mode 100644 index 00000000..a1b3baee --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java @@ -0,0 +1,41 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +@Repository +public interface JourneyItemRepository extends JpaRepository { + + /** Returns items ordered by position ASC for the read-model assembly path. */ + List findByGeschichteIdOrderByPosition(UUID geschichteId); + + /** IDOR-safe lookup: returns empty when itemId exists but belongs to a different journey. */ + Optional 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 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 findMaxPositionByGeschichteId(@Param("geschichteId") UUID geschichteId); + + /** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */ + long countByGeschichteId(UUID geschichteId); + + /** + * Loads journey items with their linked Document in a single JOIN FETCH query, + * eliminating the N+1 SELECT that would occur when accessing item.getDocument() + * lazily for each item. Items without a document (note-only) are included via + * LEFT JOIN. Ordered by position ASC. + */ + @Query("SELECT ji FROM JourneyItem ji LEFT JOIN FETCH ji.document WHERE ji.geschichte.id = :geschichteId ORDER BY ji.position ASC") + List findByGeschichteIdWithDocument(@Param("geschichteId") UUID geschichteId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java new file mode 100644 index 00000000..f189a9d3 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java @@ -0,0 +1,253 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.geschichte.Geschichte; +import org.raddatz.familienarchiv.geschichte.GeschichteQueryService; +import org.raddatz.familienarchiv.geschichte.GeschichteType; +import org.raddatz.familienarchiv.geschichte.PersonNameFormatter; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.UserService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Service +@RequiredArgsConstructor +@Slf4j +public class JourneyItemService { + + static final int MAX_ITEMS = 100; + static final int POSITION_STEP = 10; + static final int MAX_NOTE_LENGTH = 5000; + + private final JourneyItemRepository journeyItemRepository; + private final GeschichteQueryService geschichteQueryService; + private final DocumentService documentService; + private final AuditService auditService; + private final UserService userService; + + @Transactional + public JourneyItemView append(UUID geschichteId, JourneyItemCreateDTO dto) { + Geschichte g = geschichteQueryService.findById(geschichteId) + .orElseThrow(() -> DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, + "Journey not found: " + geschichteId)); + + if (g.getType() != GeschichteType.JOURNEY) { + throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_MISMATCH, + "Journey items can only be added to a JOURNEY-type Geschichte"); + } + + long count = journeyItemRepository.countByGeschichteId(geschichteId); + if (count >= MAX_ITEMS) { + throw DomainException.conflict(ErrorCode.JOURNEY_AT_CAPACITY, + "Journey has reached the maximum of 100 items"); + } + + String note = normalizeNote(dto.getNote()); + + if (dto.getDocumentId() == null && note == null) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "At least one of documentId or note must be provided"); + } + + if (note != null && note.length() > MAX_NOTE_LENGTH) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters"); + } + + Document doc = null; + if (dto.getDocumentId() != null) { + doc = documentService.findSummaryByIdInternal(dto.getDocumentId()); + } + + int nextPosition = journeyItemRepository.findMaxPositionByGeschichteId(geschichteId) + .map(max -> max + POSITION_STEP) + .orElse(POSITION_STEP); + + JourneyItem item = JourneyItem.builder() + .geschichte(g) + .position(nextPosition) + .document(doc) + .note(note) + .build(); + JourneyItem saved = journeyItemRepository.save(item); + + UUID actorId = currentUser().getId(); + auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_ADDED, actorId, null, + Map.of("geschichteId", geschichteId, "itemId", saved.getId())); + + return toView(saved); + } + + @Transactional + public JourneyItemView updateNote(UUID geschichteId, UUID itemId, JourneyItemUpdateDTO dto) { + JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId) + .orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, + "Journey item not found: " + itemId)); + + // null = field absent from JSON → no-op + Optional noteField = dto.getNote(); + if (noteField == null) { + return toView(item); + } + + String note = normalizeNote(noteField.orElse(null)); + + if (note != null && note.length() > MAX_NOTE_LENGTH) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters"); + } + + if (note == null && item.getDocumentId() == null) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "Cannot clear note on an item that has no linked document"); + } + + item.setNote(note); + JourneyItem saved = journeyItemRepository.save(item); + + UUID actorId = currentUser().getId(); + auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_NOTE_UPDATED, actorId, null, + Map.of("geschichteId", geschichteId, "itemId", itemId)); + + return toView(saved); + } + + @Transactional + public void delete(UUID geschichteId, UUID itemId) { + JourneyItem item = journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId) + .orElseThrow(() -> DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, + "Journey item not found: " + itemId)); + + journeyItemRepository.delete(item); + + UUID actorId = currentUser().getId(); + auditService.logAfterCommit(AuditKind.JOURNEY_ITEM_REMOVED, actorId, null, + Map.of("geschichteId", geschichteId, "itemId", itemId)); + } + + @Transactional + public List reorder(UUID geschichteId, JourneyReorderDTO dto) { + if (!geschichteQueryService.existsById(geschichteId)) { + throw DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, + "Journey not found: " + geschichteId); + } + Set existingIds = journeyItemRepository.findIdsByGeschichteId(geschichteId); + List 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 items = journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId); + Map itemMap = new HashMap<>(); + for (JourneyItem item : items) { + itemMap.put(item.getId(), item); + } + + List 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 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 getItems(UUID geschichteId) { + return journeyItemRepository.findByGeschichteIdWithDocument(geschichteId) + .stream().map(this::toView).toList(); + } + + DocumentSummary toSummary(Document doc) { + String senderName = buildSenderName(doc); + Set 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 receivers) { + if (receivers == null || receivers.isEmpty()) return null; + return receivers.stream() + .min(Comparator.comparing(p -> sortKey(p.getLastName()) + " " + sortKey(p.getFirstName()))) + .map(p -> { + String name = PersonNameFormatter.join(p.getFirstName(), p.getLastName()); + return name.isBlank() ? null : name; + }) + .orElse(null); + } + + private static String normalizeNote(String raw) { + if (raw == null || raw.isBlank()) return null; + return raw.trim(); + } + + private static String sortKey(String s) { + return s != null ? s : ""; + } + + private AppUser currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + throw DomainException.unauthorized("Authentication required"); + } + return userService.findByEmail(auth.getName()); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java new file mode 100644 index 00000000..1e63ac9c --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java @@ -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: + * 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 note = null; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemView.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemView.java new file mode 100644 index 00000000..141860af --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemView.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +/** + * Read-model response for a JourneyItem. Never the JPA entity (which has a + * Geschichte back-reference that would leak / hit LazyInitializationException). + */ +public record JourneyItemView( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) int position, + DocumentSummary document, + String note +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyReorderDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyReorderDTO.java new file mode 100644 index 00000000..b11f343b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyReorderDTO.java @@ -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 itemIds; +} diff --git a/backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql b/backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql new file mode 100644 index 00000000..3757e267 --- /dev/null +++ b/backend/src/main/resources/db/migration/V72__add_journey_items_migrate_geschichten_documents.sql @@ -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; diff --git a/backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql b/backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql new file mode 100644 index 00000000..76a8af2f --- /dev/null +++ b/backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql @@ -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); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java index 4d13363b..9c7bf25c 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteControllerTest.java @@ -2,15 +2,13 @@ package org.raddatz.familienarchiv.geschichte; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; -import org.raddatz.familienarchiv.security.SecurityConfig; -import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; -import org.raddatz.familienarchiv.geschichte.Geschichte; -import org.raddatz.familienarchiv.geschichte.GeschichteStatus; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView; +import org.raddatz.familienarchiv.security.SecurityConfig; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.user.CustomUserDetailsService; -import org.raddatz.familienarchiv.geschichte.GeschichteService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -21,22 +19,25 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.UUID; +import static org.hamcrest.CoreMatchers.nullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @WebMvcTest(GeschichteController.class) @Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) @@ -47,11 +48,9 @@ class GeschichteControllerTest { private final ObjectMapper objectMapper = new ObjectMapper(); - @MockitoBean - GeschichteService geschichteService; - - @MockitoBean - CustomUserDetailsService customUserDetailsService; + @MockitoBean GeschichteService geschichteService; + @MockitoBean JourneyItemService journeyItemService; + @MockitoBean CustomUserDetailsService customUserDetailsService; // ─── GET /api/geschichten ──────────────────────────────────────────────── @@ -64,8 +63,8 @@ class GeschichteControllerTest { @Test @WithMockUser(authorities = "READ_ALL") void list_returns200_forReader() throws Exception { - when(geschichteService.list(any(), any(), any(), anyInt())) - .thenReturn(List.of(published(UUID.randomUUID(), "Story A"))); + when(geschichteService.list(any(), any(), anyInt())) + .thenReturn(List.of(summaryStub("Story A"))); mockMvc.perform(get("/api/geschichten")) .andExpect(status().isOk()) @@ -76,13 +75,13 @@ class GeschichteControllerTest { @WithMockUser(authorities = "READ_ALL") void list_passesSinglePersonIdFilterToServiceAsListOfOne() throws Exception { UUID personId = UUID.randomUUID(); - when(geschichteService.list(any(), eq(List.of(personId)), any(), anyInt())) + when(geschichteService.list(any(), eq(List.of(personId)), anyInt())) .thenReturn(List.of()); mockMvc.perform(get("/api/geschichten").param("personId", personId.toString())) .andExpect(status().isOk()); - verify(geschichteService).list(any(), eq(List.of(personId)), any(), anyInt()); + verify(geschichteService).list(any(), eq(List.of(personId)), anyInt()); } @Test @@ -90,7 +89,7 @@ class GeschichteControllerTest { void list_passesRepeatedPersonIdParamsAsListForAndFilter() throws Exception { UUID a = UUID.randomUUID(); UUID b = UUID.randomUUID(); - when(geschichteService.list(any(), eq(List.of(a, b)), any(), anyInt())) + when(geschichteService.list(any(), eq(List.of(a, b)), anyInt())) .thenReturn(List.of()); mockMvc.perform(get("/api/geschichten") @@ -98,7 +97,7 @@ class GeschichteControllerTest { .param("personId", b.toString())) .andExpect(status().isOk()); - verify(geschichteService).list(any(), eq(List.of(a, b)), any(), anyInt()); + verify(geschichteService).list(any(), eq(List.of(a, b)), anyInt()); } // ─── GET /api/geschichten/{id} ─────────────────────────────────────────── @@ -107,7 +106,7 @@ class GeschichteControllerTest { @WithMockUser(authorities = "READ_ALL") void getById_returns200_whenFound() throws Exception { UUID id = UUID.randomUUID(); - when(geschichteService.getById(id)).thenReturn(published(id, "Hello")); + when(geschichteService.getView(id)).thenReturn(viewStub(id, "Hello")); mockMvc.perform(get("/api/geschichten/{id}", id)) .andExpect(status().isOk()) @@ -119,7 +118,7 @@ class GeschichteControllerTest { @WithMockUser(authorities = "READ_ALL") void getById_returns404_whenServiceThrowsNotFound() throws Exception { UUID id = UUID.randomUUID(); - when(geschichteService.getById(id)) + when(geschichteService.getView(id)) .thenThrow(DomainException.notFound(ErrorCode.GESCHICHTE_NOT_FOUND, "x")); mockMvc.perform(get("/api/geschichten/{id}", id)) @@ -208,8 +207,180 @@ class GeschichteControllerTest { verify(geschichteService).delete(id); } + // ─── POST /api/geschichten/{id}/items ──────────────────────────────────── + + @Test + void appendItem_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"x\"}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void appendItem_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(post("/api/geschichten/{id}/items", UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"x\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void appendItem_returns201_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.append(eq(id), any())).thenReturn(itemViewStub(itemId, 10, "Note")); + + mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"Note\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(itemId.toString())) + .andExpect(jsonPath("$.position").value(10)); + } + + // ─── PATCH /api/geschichten/{id}/items/{itemId} ────────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void updateItemNote_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", + UUID.randomUUID(), UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"x\"}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void updateItemNote_returns200_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.updateNote(eq(id), eq(itemId), any())) + .thenReturn(itemViewStub(itemId, 10, "Updated")); + + mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"Updated\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.note").value("Updated")); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void updateItemNote_json_null_note_is_deserialized_as_empty_Optional() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.updateNote(eq(id), eq(itemId), any())) + .thenReturn(itemViewStub(itemId, 10, null)); + + // Raw JSON — local objectMapper lacks JsonNullableModule + mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\": null}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.note").value(nullValue())); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void updateItemNote_returns404_whenItemNotFound() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.updateNote(eq(id), eq(itemId), any())) + .thenThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found")); + + mockMvc.perform(patch("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"x\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND")); + } + + // ─── DELETE /api/geschichten/{id}/items/{itemId} ───────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void deleteItem_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", + UUID.randomUUID(), UUID.randomUUID()).with(csrf())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void deleteItem_returns204_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + + mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())) + .andExpect(status().isNoContent()); + + verify(journeyItemService).delete(id, itemId); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void deleteItem_returns404_whenItemNotFound() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + org.mockito.Mockito.doThrow(DomainException.notFound(ErrorCode.JOURNEY_ITEM_NOT_FOUND, "not found")) + .when(journeyItemService).delete(id, itemId); + + mockMvc.perform(delete("/api/geschichten/{id}/items/{itemId}", id, itemId).with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_NOT_FOUND")); + } + + // ─── PUT /api/geschichten/{id}/items/reorder ───────────────────────────── + + @Test + @WithMockUser(authorities = "READ_ALL") + void reorderItems_returns403_whenLackingBlogWrite() throws Exception { + mockMvc.perform(put("/api/geschichten/{id}/items/reorder", UUID.randomUUID()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"itemIds\":[]}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void reorderItems_returns200_withBlogWrite() throws Exception { + UUID id = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + when(journeyItemService.reorder(eq(id), any())).thenReturn(List.of(itemViewStub(itemId, 10, null))); + + mockMvc.perform(put("/api/geschichten/{id}/items/reorder", id).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"itemIds\":[\"" + itemId + "\"]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(itemId.toString())); + } + + // ─── error mapping ─────────────────────────────────────────────────────── + + @Test + @WithMockUser(authorities = "BLOG_WRITE") + void appendItem_returns409_on_position_conflict() throws Exception { + UUID id = UUID.randomUUID(); + when(journeyItemService.append(eq(id), any())) + .thenThrow(DomainException.conflict(ErrorCode.JOURNEY_ITEM_POSITION_CONFLICT, "conflict")); + + mockMvc.perform(post("/api/geschichten/{id}/items", id).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"note\":\"x\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("JOURNEY_ITEM_POSITION_CONFLICT")); + } + // ─── helpers ───────────────────────────────────────────────────────────── + private JourneyItemView itemViewStub(UUID id, int position, String note) { + return new JourneyItemView(id, position, null, note); + } + private Geschichte published(UUID id, String title) { return Geschichte.builder() .id(id) @@ -220,7 +391,7 @@ class GeschichteControllerTest { .createdAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now()) .persons(new HashSet<>()) - .documents(new HashSet<>()) + .items(new ArrayList<>()) .build(); } @@ -232,7 +403,27 @@ class GeschichteControllerTest { .createdAt(LocalDateTime.now()) .updatedAt(LocalDateTime.now()) .persons(new HashSet<>()) - .documents(new HashSet<>()) + .items(new ArrayList<>()) .build(); } + + private GeschichteView viewStub(UUID id, String title) { + return new GeschichteView(id, title, "

x

", + GeschichteStatus.PUBLISHED, GeschichteType.STORY, + null, new HashSet<>(), List.of(), + LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); + } + + /** Concrete implementation — Mockito interface mocks are not serialized reliably by Jackson. */ + private GeschichteSummary summaryStub(String title) { + return new GeschichteSummary() { + public UUID getId() { return UUID.randomUUID(); } + public String getTitle() { return title; } + public GeschichteStatus getStatus() { return GeschichteStatus.PUBLISHED; } + public GeschichteType getType() { return GeschichteType.STORY; } + public AuthorSummary getAuthor() { return null; } + public LocalDateTime getPublishedAt() { return LocalDateTime.now(); } + public String getBody() { return null; } + }; + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java new file mode 100644 index 00000000..025f5296 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteHttpTest.java @@ -0,0 +1,227 @@ +package org.raddatz.familienarchiv.geschichte; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItem; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.AppUserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.RestTemplate; +import software.amazon.awssdk.services.s3.S3Client; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies Geschichte HTTP behaviour end-to-end at the real servlet layer. + * + *

No {@code @Transactional} at class level — that would keep a session open and + * mask LazyInitializationException caused by open-in-view: false. Each test seeds data + * directly via repositories and relies on the service's own transaction boundaries. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +class GeschichteHttpTest { + + @LocalServerPort int port; + @MockitoBean S3Client s3Client; + + @Autowired GeschichteRepository geschichteRepository; + @Autowired AppUserRepository appUserRepository; + @Autowired PasswordEncoder passwordEncoder; + + private RestTemplate http; + private String baseUrl; + + private static final String WRITER_EMAIL = "geschichten-http-writer@test.de"; + private static final String WRITER_PASSWORD = "pass!Geschichte1"; + + @BeforeEach + void setUp() { + http = noThrowRestTemplate(); + baseUrl = "http://localhost:" + port; + geschichteRepository.deleteAll(); + appUserRepository.findByEmail(WRITER_EMAIL).ifPresent(appUserRepository::delete); + appUserRepository.save(AppUser.builder() + .email(WRITER_EMAIL) + .password(passwordEncoder.encode(WRITER_PASSWORD)) + .build()); + } + + // ─── GET /api/geschichten ──────────────────────────────────────────────── + + @Test + void list_returns_200_and_empty_array_when_no_stories_exist() { + String session = loginAsWriter(); + ResponseEntity 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 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 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 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 response = http.exchange( + baseUrl + "/api/geschichten/" + saved.getId(), HttpMethod.GET, + new HttpEntity<>(sessionHeaders(session)), String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private String loginAsWriter() { + String xsrf = UUID.randomUUID().toString(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Cookie", "XSRF-TOKEN=" + xsrf); + headers.set("X-XSRF-TOKEN", xsrf); + String body = "{\"email\":\"" + WRITER_EMAIL + "\",\"password\":\"" + WRITER_PASSWORD + "\"}"; + ResponseEntity 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 setCookieHeader = response.getHeaders().get("Set-Cookie"); + if (setCookieHeader == null) return ""; + return setCookieHeader.stream() + .filter(c -> c.startsWith("fa_session=")) + .map(c -> c.split(";")[0].substring("fa_session=".length())) + .findFirst() + .orElse(""); + } + + private RestTemplate noThrowRestTemplate() { + RestTemplate template = new RestTemplate(); + template.setErrorHandler(new DefaultResponseErrorHandler() { + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + return false; + } + }); + return template; + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java new file mode 100644 index 00000000..ec0d79a5 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteListProjectionTest.java @@ -0,0 +1,196 @@ +package org.raddatz.familienarchiv.geschichte; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.AppUserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class GeschichteListProjectionTest { + + @Autowired GeschichteRepository geschichteRepository; + @Autowired AppUserRepository appUserRepository; + @Autowired PersonRepository personRepository; + + AppUser author; + AppUser otherAuthor; + + @BeforeEach + void setUp() { + geschichteRepository.deleteAll(); + author = appUserRepository.save(AppUser.builder() + .email("author@test").password("pw").build()); + otherAuthor = appUserRepository.save(AppUser.builder() + .email("other@test").password("pw").build()); + } + + // ─── findSummaries returns only the requested status ───────────────────── + + @Test + void findSummaries_returns_only_published_stories_when_effectiveStatus_is_PUBLISHED() { + geschichteRepository.save(published("Veröffentlicht", author)); + geschichteRepository.save(draft("Entwurf", author)); + + List result = geschichteRepository.findSummaries( + GeschichteStatus.PUBLISHED, null, sentinel(), 0); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTitle()).isEqualTo("Veröffentlicht"); + } + + @Test + void findSummaries_returns_empty_list_when_no_published_geschichten_exist() { + geschichteRepository.save(draft("Nur Entwurf", author)); + + List result = geschichteRepository.findSummaries( + GeschichteStatus.PUBLISHED, null, sentinel(), 0); + + assertThat(result).isEmpty(); + } + + // ─── AuthorSummary nested projection ───────────────────────────────────── + + @Test + void findSummaries_exposes_nested_author_firstName_lastName_email() { + AppUser richAuthor = appUserRepository.save(AppUser.builder() + .email("franz@raddatz.de").password("pw").build()); + geschichteRepository.save(published("Briefe aus der Front", richAuthor)); + + List result = geschichteRepository.findSummaries( + GeschichteStatus.PUBLISHED, null, sentinel(), 0); + + assertThat(result).hasSize(1); + GeschichteSummary.AuthorSummary a = result.get(0).getAuthor(); + assertThat(a.getEmail()).isEqualTo("franz@raddatz.de"); + } + + // ─── GeschichteType is exposed ──────────────────────────────────────────── + + @Test + void findSummaries_exposes_type_field() { + Geschichte journey = Geschichte.builder() + .title("Eine Reise") + .status(GeschichteStatus.PUBLISHED) + .type(GeschichteType.JOURNEY) + .author(author) + .publishedAt(LocalDateTime.now()) + .build(); + geschichteRepository.save(journey); + + List result = geschichteRepository.findSummaries( + GeschichteStatus.PUBLISHED, null, sentinel(), 0); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getType()).isEqualTo(GeschichteType.JOURNEY); + } + + // ─── authorId filter (own-drafts gate) ─────────────────────────────────── + + @Test + void findSummaries_with_authorId_returns_only_own_drafts() { + geschichteRepository.save(draft("Mein Entwurf", author)); + geschichteRepository.save(draft("Fremder Entwurf", otherAuthor)); + + List result = geschichteRepository.findSummaries( + GeschichteStatus.DRAFT, author.getId(), sentinel(), 0); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTitle()).isEqualTo("Mein Entwurf"); + } + + // ─── personCount = 0 → no person filter ────────────────────────────────── + + @Test + void findSummaries_with_personCount_zero_ignores_personIds_and_returns_all() { + geschichteRepository.save(published("A", author)); + geschichteRepository.save(published("B", author)); + + List result = geschichteRepository.findSummaries( + GeschichteStatus.PUBLISHED, null, sentinel(), 0); + + assertThat(result).hasSize(2); + } + + // ─── personCount > 0 AND-semantics ─────────────────────────────────────── + + @Test + void findSummaries_with_one_personId_returns_only_linked_stories() { + Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build()); + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build()); + + Geschichte withFranz = published("Franz story", author); + withFranz.getPersons().add(franz); + geschichteRepository.save(withFranz); + + Geschichte withAnna = published("Anna story", author); + withAnna.getPersons().add(anna); + geschichteRepository.save(withAnna); + + List result = geschichteRepository.findSummaries( + GeschichteStatus.PUBLISHED, null, List.of(franz.getId()), 1); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTitle()).isEqualTo("Franz story"); + } + + @Test + void findSummaries_with_two_personIds_uses_AND_semantics() { + Person franz = personRepository.save(Person.builder().firstName("Franz").lastName("R").build()); + Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("R").build()); + + Geschichte both = published("Both", author); + both.getPersons().add(franz); + both.getPersons().add(anna); + geschichteRepository.save(both); + + Geschichte onlyFranz = published("Only Franz", author); + onlyFranz.getPersons().add(franz); + geschichteRepository.save(onlyFranz); + + List result = geschichteRepository.findSummaries( + GeschichteStatus.PUBLISHED, null, List.of(franz.getId(), anna.getId()), 2); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getTitle()).isEqualTo("Both"); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private Geschichte published(String title, AppUser writer) { + return Geschichte.builder() + .title(title) + .status(GeschichteStatus.PUBLISHED) + .author(writer) + .publishedAt(LocalDateTime.now()) + .build(); + } + + private Geschichte draft(String title, AppUser writer) { + return Geschichte.builder() + .title(title) + .status(GeschichteStatus.DRAFT) + .author(writer) + .build(); + } + + /** Sentinel UUID passed when personCount=0 — the IN() clause is never evaluated. */ + private List sentinel() { + return List.of(UUID.fromString("00000000-0000-0000-0000-000000000000")); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryServiceTest.java new file mode 100644 index 00000000..8cdc84db --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryServiceTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java index 31c73af1..11db3c1a 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceIntegrationTest.java @@ -8,9 +8,12 @@ import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO; import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.geschichte.Geschichte; import org.raddatz.familienarchiv.geschichte.GeschichteStatus; +import org.raddatz.familienarchiv.geschichte.GeschichteType; +import org.raddatz.familienarchiv.geschichte.GeschichteView; import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.user.AppUserRepository; import org.raddatz.familienarchiv.geschichte.GeschichteRepository; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService; import org.raddatz.familienarchiv.person.PersonRepository; import org.raddatz.familienarchiv.security.Permission; import org.springframework.beans.factory.annotation.Autowired; @@ -39,6 +42,7 @@ class GeschichteServiceIntegrationTest { S3Client s3Client; @Autowired GeschichteService geschichteService; + @Autowired JourneyItemService journeyItemService; @Autowired GeschichteRepository geschichteRepository; @Autowired PersonRepository personRepository; @Autowired AppUserRepository appUserRepository; @@ -86,7 +90,7 @@ class GeschichteServiceIntegrationTest { // Reader cannot see DRAFT in list authenticateAs(reader, Permission.READ_ALL); - assertThat(geschichteService.list(null, List.of(), null, 50)).isEmpty(); + assertThat(geschichteService.list(null, List.of(), 50)).isEmpty(); // Reader cannot fetch DRAFT by id (404 via GESCHICHTE_NOT_FOUND) UUID draftId = created.getId(); @@ -102,11 +106,12 @@ class GeschichteServiceIntegrationTest { // Reader can now see and fetch it authenticateAs(reader, Permission.READ_ALL); - assertThat(geschichteService.list(null, List.of(), null, 50)).hasSize(1); - assertThat(geschichteService.list(null, List.of(franz.getId()), null, 50)).hasSize(1); + assertThat(geschichteService.list(null, List.of(), 50)).hasSize(1); + assertThat(geschichteService.list(null, List.of(franz.getId()), 50)).hasSize(1); Geschichte fetched = geschichteService.getById(draftId); - assertThat(fetched.getTitle()).isEqualTo("Erinnerung an Opa Franz"); - assertThat(fetched.getPersons()).extracting(Person::getId).containsExactly(franz.getId()); + GeschichteView fetchedView = geschichteService.toView(fetched, journeyItemService.getItems(draftId)); + assertThat(fetchedView.title()).isEqualTo("Erinnerung an Opa Franz"); + assertThat(fetchedView.persons()).extracting(GeschichteView.PersonView::id).containsExactly(franz.getId()); // Delete as writer; join rows go with it authenticateAs(writer, Permission.BLOG_WRITE); @@ -136,26 +141,26 @@ class GeschichteServiceIntegrationTest { authenticateAs(reader, Permission.READ_ALL); // No filter → all three - assertThat(geschichteService.list(null, List.of(), null, 50)) - .extracting(Geschichte::getId) + assertThat(geschichteService.list(null, List.of(), 50)) + .extracting(GeschichteSummary::getId) .containsExactlyInAnyOrder(storyAB, storyAC, storyA); // Single filter (Anna) → all three - assertThat(geschichteService.list(null, List.of(a.getId()), null, 50)) - .extracting(Geschichte::getId) + assertThat(geschichteService.list(null, List.of(a.getId()), 50)) + .extracting(GeschichteSummary::getId) .containsExactlyInAnyOrder(storyAB, storyAC, storyA); // AND: Anna AND Bertha → only the AB story (NOT story_A, NOT story_AC) - assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), null, 50)) - .extracting(Geschichte::getId) + assertThat(geschichteService.list(null, List.of(a.getId(), b.getId()), 50)) + .extracting(GeschichteSummary::getId) .containsExactly(storyAB); // AND: Bertha AND Carl → none (no story has both) - assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), null, 50)) + assertThat(geschichteService.list(null, List.of(b.getId(), c.getId()), 50)) .isEmpty(); // AND: Anna AND Bertha AND Carl → none - assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), null, 50)) + assertThat(geschichteService.list(null, List.of(a.getId(), b.getId(), c.getId()), 50)) .isEmpty(); } @@ -174,7 +179,7 @@ class GeschichteServiceIntegrationTest { geschichteService.create(dto); authenticateAs(writer2, Permission.BLOG_WRITE); - List result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), null, 50); + List result = geschichteService.list(GeschichteStatus.DRAFT, List.of(), 50); assertThat(result).isEmpty(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index 0a51d319..d2d5bfed 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -7,26 +7,22 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemService; +import org.raddatz.familienarchiv.geschichte.journeyitem.JourneyItemView; import org.raddatz.familienarchiv.user.AppUser; -import org.raddatz.familienarchiv.document.Document; -import org.raddatz.familienarchiv.geschichte.Geschichte; -import org.raddatz.familienarchiv.geschichte.GeschichteStatus; import org.raddatz.familienarchiv.person.Person; -import org.raddatz.familienarchiv.geschichte.GeschichteRepository; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.user.UserService; -import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.domain.Specification; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Optional; @@ -37,7 +33,9 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -45,17 +43,13 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class GeschichteServiceTest { - @Mock - GeschichteRepository geschichteRepository; - @Mock - PersonService personService; - @Mock - DocumentService documentService; - @Mock - UserService userService; + @Mock GeschichteRepository geschichteRepository; + @Mock PersonService personService; + @Mock DocumentService documentService; + @Mock UserService userService; + @Mock JourneyItemService journeyItemService; - @InjectMocks - GeschichteService geschichteService; + @InjectMocks GeschichteService geschichteService; AppUser writer; AppUser reader; @@ -96,7 +90,8 @@ class GeschichteServiceTest { Geschichte result = geschichteService.getById(id); - assertThat(result).isSameAs(draft); + assertThat(result.getId()).isEqualTo(id); + assertThat(result.getStatus()).isEqualTo(GeschichteStatus.DRAFT); } @Test @@ -108,7 +103,8 @@ class GeschichteServiceTest { Geschichte result = geschichteService.getById(id); - assertThat(result).isSameAs(published); + assertThat(result.getId()).isEqualTo(id); + assertThat(result.getStatus()).isEqualTo(GeschichteStatus.PUBLISHED); } @Test @@ -123,79 +119,163 @@ class GeschichteServiceTest { .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND); } + // ─── getView ────────────────────────────────────────────────────────────── + + @Test + void getView_returns_assembled_view_and_delegates_to_journeyItemService() { + authenticateAs(reader, Permission.READ_ALL); + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + JourneyItemView item = new JourneyItemView(UUID.randomUUID(), 10, null, "Note"); + when(geschichteRepository.findById(id)).thenReturn(Optional.of(published)); + when(journeyItemService.getItems(id)).thenReturn(List.of(item)); + + GeschichteView view = geschichteService.getView(id); + + assertThat(view.id()).isEqualTo(id); + assertThat(view.items()).containsExactly(item); + verify(journeyItemService).getItems(id); + } + + @Test + void getView_throws_NOT_FOUND_when_id_unknown() { + authenticateAs(reader, Permission.READ_ALL); + UUID id = UUID.randomUUID(); + when(geschichteRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> geschichteService.getView(id)) + .isInstanceOf(DomainException.class) + .extracting("code") + .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND); + } + + @Test + void toView_author_displayName_uses_firstName_lastName() { + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + published.setAuthor(AppUser.builder() + .id(UUID.randomUUID()).email("author@test") + .firstName("Hans").lastName("Raddatz").build()); + + GeschichteView result = geschichteService.toView(published, List.of()); + + assertThat(result.author().displayName()).isEqualTo("Hans Raddatz"); + } + + @Test + void toView_author_displayName_falls_back_to_Unbekannt_when_names_blank() { + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + published.setAuthor(AppUser.builder() + .id(UUID.randomUUID()).email("anon@test").build()); + + GeschichteView result = geschichteService.toView(published, List.of()); + + assertThat(result.author().displayName()).isEqualTo("[Unbekannt]"); + } + + @Test + void toView_author_email_is_not_in_author_view() { + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + published.setAuthor(AppUser.builder() + .id(UUID.randomUUID()).email("secret@test") + .firstName("Max").lastName("M").build()); + + GeschichteView result = geschichteService.toView(published, List.of()); + + // AuthorView exposes only id + displayName — no email field at all + assertThat(result.author()).isInstanceOf(GeschichteView.AuthorView.class); + assertThat(result.author().displayName()).doesNotContain("secret@test"); + } + + @Test + void toView_persons_are_mapped_to_PersonView() { + UUID id = UUID.randomUUID(); + UUID personId = UUID.randomUUID(); + Geschichte published = published(id); + published.setPersons(new HashSet<>(List.of( + Person.builder().id(personId).firstName("Franz").lastName("Raddatz").build() + ))); + + GeschichteView result = geschichteService.toView(published, List.of()); + + assertThat(result.persons()).hasSize(1); + GeschichteView.PersonView pv = result.persons().iterator().next(); + assertThat(pv.id()).isEqualTo(personId); + assertThat(pv.firstName()).isEqualTo("Franz"); + assertThat(pv.lastName()).isEqualTo("Raddatz"); + } + + @Test + void toView_items_are_passed_through() { + UUID id = UUID.randomUUID(); + Geschichte published = published(id); + + GeschichteView result = geschichteService.toView(published, List.of()); + + assertThat(result.items()).isEmpty(); + } + // ─── list ───────────────────────────────────────────────────────────────── @Test void list_forces_PUBLISHED_status_for_reader_without_BLOG_WRITE() { authenticateAs(reader, Permission.READ_ALL); - when(geschichteRepository.findAll(any(Specification.class), any(Sort.class))) - .thenReturn(List.of(published(UUID.randomUUID()))); + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) + .thenReturn(List.of()); - geschichteService.list(/*status*/ null, /*personIds*/ List.of(), /*documentId*/ null, /*limit*/ 50); + geschichteService.list(null, List.of(), 50); - // Status pinning lives inside the Specification; we assert end-to-end behaviour - // in GeschichteServiceIntegrationTest. Here we just confirm the service routes - // through the spec-aware repository method. - verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class)); + verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); } @Test void list_passes_null_status_through_for_BLOG_WRITER_so_drafts_are_visible() { authenticateAs(writer, Permission.BLOG_WRITE); - when(geschichteRepository.findAll(any(Specification.class), any(Sort.class))) - .thenReturn(List.of(draft(UUID.randomUUID()), published(UUID.randomUUID()))); + GeschichteSummary s1 = mock(GeschichteSummary.class); + GeschichteSummary s2 = mock(GeschichteSummary.class); + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) + .thenReturn(List.of(s1, s2)); - List out = geschichteService.list(null, List.of(), null, 50); + List out = geschichteService.list(null, List.of(), 50); assertThat(out).hasSize(2); - verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class)); + verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); } @Test - void list_invokes_repository_findAll_when_filtering_by_single_personId() { + void list_invokes_repository_findSummaries_when_filtering_by_single_personId() { authenticateAs(reader, Permission.READ_ALL); UUID personId = UUID.randomUUID(); - when(geschichteRepository.findAll(any(Specification.class), any(Sort.class))) + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) .thenReturn(List.of()); - geschichteService.list(null, List.of(personId), null, 50); + geschichteService.list(null, List.of(personId), 50); - verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class)); + verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); } @Test - void list_invokes_repository_findAll_when_filtering_by_multiple_personIds() { + void list_invokes_repository_findSummaries_when_filtering_by_multiple_personIds() { authenticateAs(reader, Permission.READ_ALL); UUID a = UUID.randomUUID(); UUID b = UUID.randomUUID(); - when(geschichteRepository.findAll(any(Specification.class), any(Sort.class))) + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) .thenReturn(List.of()); - geschichteService.list(null, List.of(a, b), null, 50); + geschichteService.list(null, List.of(a, b), 50); - verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class)); + verify(geschichteRepository).findSummaries(any(), any(), any(), anyLong()); } @Test - void list_filters_by_documentId() { + void list_caps_limit_at_max_when_caller_passes_huge_value() { authenticateAs(reader, Permission.READ_ALL); - UUID documentId = UUID.randomUUID(); - when(geschichteRepository.findAll(any(Specification.class), any(Sort.class))) - .thenReturn(List.of()); + when(geschichteRepository.findSummaries(any(), any(), any(), anyLong())) + .thenReturn(List.of(mock(GeschichteSummary.class))); - geschichteService.list(null, List.of(), documentId, 50); - - verify(geschichteRepository).findAll(any(Specification.class), any(Sort.class)); - } - - @Test - void list_caps_limit_at_max_via_pageable_when_caller_passes_huge_value() { - authenticateAs(reader, Permission.READ_ALL); - when(geschichteRepository.findAll(any(Specification.class), any(Sort.class))) - .thenReturn(List.of(published(UUID.randomUUID()))); - - // 9999 should be clamped — service trims to MAX_LIMIT (200) before/after the query - List out = geschichteService.list(null, List.of(), null, 9999); + List out = geschichteService.list(null, List.of(), 9999); assertThat(out).hasSizeLessThanOrEqualTo(200); } @@ -282,25 +362,6 @@ class GeschichteServiceTest { assertThat(saved.getPersons()).containsExactly(person); } - @Test - void create_resolves_documentIds_via_DocumentService() { - authenticateAs(writer, Permission.BLOG_WRITE); - when(userService.findByEmail(writer.getEmail())).thenReturn(writer); - UUID docId = UUID.randomUUID(); - Document doc = Document.builder().id(docId).build(); - when(documentService.getDocumentById(docId)).thenReturn(doc); - when(geschichteRepository.save(any(Geschichte.class))) - .thenAnswer(inv -> inv.getArgument(0)); - - GeschichteUpdateDTO dto = new GeschichteUpdateDTO(); - dto.setTitle("Linked doc"); - dto.setDocumentIds(List.of(docId)); - - Geschichte saved = geschichteService.create(dto); - - assertThat(saved.getDocuments()).containsExactly(doc); - } - @Test void create_throws_BAD_REQUEST_when_title_blank() { authenticateAs(writer, Permission.BLOG_WRITE); @@ -426,7 +487,7 @@ class GeschichteServiceTest { .body("

body

") .status(GeschichteStatus.DRAFT) .persons(new HashSet<>()) - .documents(new HashSet<>()) + .items(new ArrayList<>()) .build(); } @@ -438,7 +499,7 @@ class GeschichteServiceTest { .status(GeschichteStatus.PUBLISHED) .publishedAt(LocalDateTime.now().minusHours(1)) .persons(new HashSet<>()) - .documents(new HashSet<>()) + .items(new ArrayList<>()) .build(); } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemConstraintsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemConstraintsTest.java new file mode 100644 index 00000000..a2615003 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemConstraintsTest.java @@ -0,0 +1,99 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentRepository; +import org.raddatz.familienarchiv.document.DocumentStatus; +import org.raddatz.familienarchiv.geschichte.Geschichte; +import org.raddatz.familienarchiv.geschichte.GeschichteRepository; +import org.raddatz.familienarchiv.geschichte.GeschichteStatus; +import org.raddatz.familienarchiv.geschichte.GeschichteType; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Raw-SQL constraint tests for journey_items — deliberately NOT @Transactional at class level. + * A DataIntegrityViolationException inside a class-level @Transactional marks the tx + * rollback-only and cascades into TransactionSystemException on teardown. + * Each test inserts via jdbcTemplate and uses explicit SQL teardown. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +class JourneyItemConstraintsTest { + + @MockitoBean + S3Client s3Client; + + @Autowired JdbcTemplate jdbcTemplate; + @Autowired GeschichteRepository geschichteRepository; + @Autowired DocumentRepository documentRepository; + + private UUID geschichteId; + private UUID documentId; + + @BeforeEach + void seed() { + jdbcTemplate.execute("DELETE FROM journey_items"); + Document doc = documentRepository.save(Document.builder() + .title("Constraints-Test-Doc") + .originalFilename("ct.pdf") + .status(DocumentStatus.UPLOADED) + .build()); + documentId = doc.getId(); + Geschichte g = geschichteRepository.save(Geschichte.builder() + .title("Constraints-Test-Journey") + .status(GeschichteStatus.DRAFT) + .type(GeschichteType.JOURNEY) + .build()); + geschichteId = g.getId(); + } + + @Test + void unique_constraint_is_deferrable_initially_deferred() { + Boolean condeferrable = jdbcTemplate.queryForObject( + "SELECT condeferrable FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'", + Boolean.class); + Boolean condeferred = jdbcTemplate.queryForObject( + "SELECT condeferred FROM pg_constraint WHERE conname = 'uq_journey_items_geschichte_position'", + Boolean.class); + assertThat(condeferrable).as("constraint must be deferrable").isTrue(); + assertThat(condeferred).as("constraint must be initially deferred").isTrue(); + } + + @Test + void position_check_rejects_nonpositive() { + UUID itemId = UUID.randomUUID(); + assertThatThrownBy(() -> + jdbcTemplate.update( + "INSERT INTO journey_items (id, geschichte_id, position, note) VALUES (?, ?, ?, ?)", + itemId, geschichteId, 0, "test")) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void unique_constraint_rejects_duplicate_position_per_geschichte() { + jdbcTemplate.update( + "INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)", + UUID.randomUUID(), geschichteId, 10, documentId); + + assertThatThrownBy(() -> + jdbcTemplate.update( + "INSERT INTO journey_items (id, geschichte_id, position, document_id) VALUES (?, ?, ?, ?)", + UUID.randomUUID(), geschichteId, 10, documentId)) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java new file mode 100644 index 00000000..b6c9fcbe --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java @@ -0,0 +1,294 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentRepository; +import org.raddatz.familienarchiv.document.DocumentStatus; +import org.raddatz.familienarchiv.geschichte.Geschichte; +import org.raddatz.familienarchiv.geschichte.GeschichteRepository; +import org.raddatz.familienarchiv.geschichte.GeschichteStatus; +import org.raddatz.familienarchiv.geschichte.GeschichteType; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.AppUserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; +import software.amazon.awssdk.services.s3.S3Client; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@Transactional +class JourneyItemIntegrationTest { + + @MockitoBean + S3Client s3Client; + + @PersistenceContext + EntityManager em; + + @Autowired GeschichteRepository geschichteRepository; + @Autowired JourneyItemRepository journeyItemRepository; + @Autowired JourneyItemService journeyItemService; + @Autowired DocumentRepository documentRepository; + @Autowired AppUserRepository appUserRepository; + + Geschichte journey; + Document doc; + AppUser writer; + + @BeforeEach + void seed() { + writer = appUserRepository.save(AppUser.builder() + .email("journey-writer@test") + .password("hash") + .build()); + doc = documentRepository.save(Document.builder() + .title("Testbrief") + .originalFilename("testbrief.pdf") + .status(DocumentStatus.UPLOADED) + .build()); + journey = geschichteRepository.save(Geschichte.builder() + .title("Eine Lesereise") + .status(GeschichteStatus.DRAFT) + .type(GeschichteType.JOURNEY) + .build()); + em.flush(); + em.clear(); + } + + @AfterEach + void clearSecurity() { + SecurityContextHolder.clearContext(); + } + + private void authenticateAs(AppUser user, Permission... permissions) { + var authorities = java.util.Arrays.stream(permissions) + .map(p -> new SimpleGrantedAuthority(p.name())) + .toList(); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities)); + } + + // ─── @OrderBy ───────────────────────────────────────────────────────────── + + @Test + void items_are_returned_in_position_order_regardless_of_insertion_order() { + Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow(); + + JourneyItem third = JourneyItem.builder().geschichte(managed).position(3000).document(doc).build(); + JourneyItem first = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build(); + JourneyItem second = JourneyItem.builder().geschichte(managed).position(2000).document(doc).build(); + managed.getItems().addAll(List.of(third, first, second)); + geschichteRepository.save(managed); + em.flush(); + em.clear(); + + Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow(); + List positions = reloaded.getItems().stream().map(JourneyItem::getPosition).toList(); + + assertThat(positions).containsExactly(1000, 2000, 3000); + } + + // ─── Cascade ALL + orphanRemoval ────────────────────────────────────────── + + @Test + void deleting_geschichte_cascade_deletes_all_journey_items() { + Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow(); + managed.getItems().add(JourneyItem.builder().geschichte(managed).position(1000).document(doc).build()); + managed.getItems().add(JourneyItem.builder().geschichte(managed).position(2000).note("context").build()); + geschichteRepository.save(managed); + em.flush(); + em.clear(); + + UUID geschichteId = journey.getId(); + geschichteRepository.deleteById(geschichteId); + em.flush(); + + assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty(); + } + + @Test + void removing_item_from_items_list_triggers_orphan_removal() { + Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow(); + JourneyItem item = JourneyItem.builder().geschichte(managed).position(1000).document(doc).build(); + managed.getItems().add(item); + Geschichte saved = geschichteRepository.save(managed); + em.flush(); + UUID itemId = saved.getItems().get(0).getId(); // extract before clear + em.clear(); + Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow(); + reloaded.getItems().removeIf(i -> i.getId().equals(itemId)); + geschichteRepository.save(reloaded); + em.flush(); + + assertThat(journeyItemRepository.findById(itemId)).isEmpty(); + } + + // ─── GeschichteType round-trip ──────────────────────────────────────────── + + @Test + void type_persists_as_JOURNEY_and_roundtrips() { + Geschichte reloaded = geschichteRepository.findById(journey.getId()).orElseThrow(); + assertThat(reloaded.getType()).isEqualTo(GeschichteType.JOURNEY); + } + + @Test + void type_defaults_to_STORY_for_new_geschichten() { + Geschichte story = geschichteRepository.save(Geschichte.builder() + .title("Erinnerung") + .status(GeschichteStatus.DRAFT) + .build()); + em.flush(); + em.clear(); + + Geschichte reloaded = geschichteRepository.findById(story.getId()).orElseThrow(); + assertThat(reloaded.getType()).isEqualTo(GeschichteType.STORY); + } + + // ─── Note-only item (document_id IS NULL) ───────────────────────────────── + + @Test + void note_only_item_persists_without_document() { + Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow(); + JourneyItem note = JourneyItem.builder() + .geschichte(managed).position(1000).note("Eine kurze Einleitung.").build(); + managed.getItems().add(note); + Geschichte saved = geschichteRepository.save(managed); + em.flush(); + UUID noteId = saved.getItems().get(0).getId(); // extract before clear + em.clear(); + JourneyItem reloaded = journeyItemRepository.findById(noteId).orElseThrow(); + assertThat(reloaded.getDocumentId()).isNull(); + assertThat(reloaded.getNote()).isEqualTo("Eine kurze Einleitung."); + } + + // ─── Document-backed item exposes documentId ────────────────────────────── + + @Test + void document_backed_item_exposes_document_uuid_via_getDocumentId() { + Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow(); + JourneyItem item = JourneyItem.builder() + .geschichte(managed).position(1000).document(doc).build(); + managed.getItems().add(item); + Geschichte saved = geschichteRepository.save(managed); + em.flush(); + UUID itemId = saved.getItems().get(0).getId(); // extract before clear + em.clear(); + JourneyItem reloaded = journeyItemRepository.findById(itemId).orElseThrow(); + assertThat(reloaded.getDocumentId()).isEqualTo(doc.getId()); + } + + // ─── ON DELETE SET NULL ─────────────────────────────────────────────────── + + @Test + void deleting_document_sets_item_document_to_null_not_delete_item() { + Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow(); + JourneyItem item = JourneyItem.builder() + .geschichte(managed).position(1000).document(doc).note("still here").build(); + managed.getItems().add(item); + Geschichte saved = geschichteRepository.save(managed); + em.flush(); + UUID itemId = saved.getItems().get(0).getId(); // extract before clear + em.clear(); + + // Delete document — ON DELETE SET NULL fires at DB level + documentRepository.deleteById(doc.getId()); + em.flush(); + em.clear(); + + JourneyItem surviving = journeyItemRepository.findById(itemId).orElseThrow(); + assertThat(surviving.getDocumentId()).isNull(); + assertThat(surviving.getNote()).isEqualTo("still here"); + } + + // ─── CHECK constraint: document_id IS NOT NULL OR note IS NOT NULL ───────── + + @Test + void saving_item_with_neither_document_nor_note_violates_check_constraint() { + Geschichte managed = geschichteRepository.findById(journey.getId()).orElseThrow(); + JourneyItem empty = JourneyItem.builder() + .geschichte(managed).position(1000).build(); + + assertThatThrownBy(() -> { + journeyItemRepository.save(empty); + journeyItemRepository.flush(); + }).isInstanceOf(Exception.class); + } + + // ─── JourneyItemService.append — end-to-end persistence ────────────────── + + @Test + void append_persists_item_at_position_10() { + // Arrange: authenticate as a user with BLOG_WRITE + authenticateAs(writer, Permission.BLOG_WRITE); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setNote("First stop"); + + // Act + JourneyItemView view = journeyItemService.append(journey.getId(), dto); + em.flush(); + em.clear(); + + // Assert: item exists in DB at position 10 + assertThat(view.position()).isEqualTo(10); + assertThat(view.note()).isEqualTo("First stop"); + List persisted = journeyItemRepository.findByGeschichteIdOrderByPosition(journey.getId()); + assertThat(persisted).hasSize(1); + assertThat(persisted.get(0).getPosition()).isEqualTo(10); + assertThat(persisted.get(0).getNote()).isEqualTo("First stop"); + } + + // ─── JourneyItemService.reorder — atomicity check ──────────────────────── + + @Test + void reorder_swaps_positions_atomically() { + // Arrange: append two items (pos 10, pos 20) + authenticateAs(writer, Permission.BLOG_WRITE); + + JourneyItemCreateDTO dto1 = new JourneyItemCreateDTO(); + dto1.setNote("Item one"); + JourneyItemView item1View = journeyItemService.append(journey.getId(), dto1); + + JourneyItemCreateDTO dto2 = new JourneyItemCreateDTO(); + dto2.setNote("Item two"); + JourneyItemView item2View = journeyItemService.append(journey.getId(), dto2); + + assertThat(item1View.position()).isEqualTo(10); + assertThat(item2View.position()).isEqualTo(20); + + // Act: reorder with [item2, item1] + JourneyReorderDTO reorderDto = new JourneyReorderDTO(); + reorderDto.setItemIds(List.of(item2View.id(), item1View.id())); + List reordered = journeyItemService.reorder(journey.getId(), reorderDto); + em.flush(); + em.clear(); + + // Assert: item2 is now at position 10, item1 is at position 20 + List 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); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java new file mode 100644 index 00000000..c411a3bb --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java @@ -0,0 +1,689 @@ +package org.raddatz.familienarchiv.geschichte.journeyitem; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.audit.AuditKind; +import org.raddatz.familienarchiv.audit.AuditService; +import org.raddatz.familienarchiv.document.DatePrecision; +import org.raddatz.familienarchiv.document.Document; +import org.raddatz.familienarchiv.document.DocumentService; +import org.raddatz.familienarchiv.document.DocumentStatus; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.geschichte.Geschichte; +import org.raddatz.familienarchiv.geschichte.GeschichteQueryService; +import org.raddatz.familienarchiv.geschichte.GeschichteStatus; +import org.raddatz.familienarchiv.geschichte.GeschichteType; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.user.AppUser; +import org.raddatz.familienarchiv.user.UserService; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JourneyItemServiceTest { + + @Mock JourneyItemRepository journeyItemRepository; + @Mock GeschichteQueryService geschichteQueryService; + @Mock DocumentService documentService; + @Mock AuditService auditService; + @Mock UserService userService; + + @InjectMocks JourneyItemService journeyItemService; + + UUID geschichteId = UUID.randomUUID(); + UUID itemId = UUID.randomUUID(); + UUID docId = UUID.randomUUID(); + UUID actorId = UUID.randomUUID(); + + @BeforeEach + void setupAuth() { + AppUser actor = AppUser.builder().id(actorId).email("test@test.de").build(); + lenient().when(userService.findByEmail("test@test.de")).thenReturn(actor); + lenient().when(geschichteQueryService.existsById(geschichteId)).thenReturn(true); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("test@test.de", null, + List.of(new SimpleGrantedAuthority("BLOG_WRITE")))); + } + + // ─── toSummary — name composition ──────────────────────────────────────── + + @Test + void toSummary_uses_linked_person_firstName_lastName() { + Person sender = Person.builder().firstName("Franz").lastName("Raddatz").build(); + Document doc = makeDoc(docId, sender, List.of(), null, null); + + var summary = journeyItemService.toSummary(doc); + + assertThat(summary.senderName()).isEqualTo("Franz Raddatz"); + } + + @Test + void toSummary_falls_back_to_senderText_when_no_person() { + Document doc = makeDoc(docId, null, List.of(), "Familie Müller", null); + + var summary = journeyItemService.toSummary(doc); + + assertThat(summary.senderName()).isEqualTo("Familie Müller"); + } + + @Test + void toSummary_returns_null_senderName_when_neither_person_nor_text() { + Document doc = makeDoc(docId, null, List.of(), null, null); + + var summary = journeyItemService.toSummary(doc); + + assertThat(summary.senderName()).isNull(); + } + + @Test + void toSummary_receiverCount_0_and_null_name_when_no_receiver() { + Document doc = makeDoc(docId, null, List.of(), null, null); + + var summary = journeyItemService.toSummary(doc); + + assertThat(summary.receiverCount()).isEqualTo(0); + assertThat(summary.receiverName()).isNull(); + } + + @Test + void toSummary_multi_receiver_returns_first_canonical_name_and_total_count() { + Person emma = Person.builder().firstName("Emma").lastName("Raddatz").build(); + Person anna = Person.builder().firstName("Anna").lastName("Amann").build(); + Document doc = makeDoc(docId, null, List.of(emma, anna), null, null); + + var summary = journeyItemService.toSummary(doc); + + assertThat(summary.receiverCount()).isEqualTo(2); + assertThat(summary.receiverName()).isEqualTo("Anna Amann"); // alphabetically first by lastName + } + + @Test + void toSummary_datePrecision_SEASON_roundtrips() { + Document doc = makeDoc(docId, null, List.of(), null, null); + doc.setMetaDatePrecision(DatePrecision.SEASON); + + var summary = journeyItemService.toSummary(doc); + + assertThat(summary.datePrecision()).isEqualTo(DatePrecision.SEASON); + } + + @Test + void toSummary_datePrecision_APPROX_roundtrips() { + Document doc = makeDoc(docId, null, List.of(), null, null); + doc.setMetaDatePrecision(DatePrecision.APPROX); + + var summary = journeyItemService.toSummary(doc); + + assertThat(summary.datePrecision()).isEqualTo(DatePrecision.APPROX); + } + + // ─── append ────────────────────────────────────────────────────────────── + + @Test + void append_to_empty_journey_starts_at_10() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L); + when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty()); + JourneyItem saved = savedItem(itemId, journey, 10, null, "Note"); + when(journeyItemRepository.save(any())).thenReturn(saved); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setNote("Note"); + + JourneyItemView view = journeyItemService.append(geschichteId, dto); + + assertThat(view.position()).isEqualTo(10); + } + + @Test + void append_after_reorder_continues_from_max_position() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(2L); + when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(40)); + JourneyItem saved = savedItem(itemId, journey, 50, null, "Note"); + when(journeyItemRepository.save(any())).thenReturn(saved); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setNote("Note"); + + JourneyItemView view = journeyItemService.append(geschichteId, dto); + + assertThat(view.position()).isEqualTo(50); + } + + @Test + void append_returns400_when_neither_documentId_nor_note() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("documentId or note"); + } + + @Test + void append_returns400_when_note_trims_to_empty_and_no_document() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setNote(" \n "); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(DomainException.class); + } + + @Test + void append_returns400_when_note_exceeds_5000_chars() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setNote("x".repeat(5001)); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR)); + } + + @Test + void append_returns409_on_non_JOURNEY_type() { + Geschichte story = Geschichte.builder() + .id(geschichteId) + .title("Story") + .type(GeschichteType.STORY) + .status(GeschichteStatus.DRAFT) + .build(); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(story)); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setNote("Note"); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.GESCHICHTE_TYPE_MISMATCH)); + } + + @Test + void append_never_calls_findSummaryByIdInternal_when_geschichte_type_is_STORY() { + // Arrange: mock geschichteQueryService.findById() to return a STORY-type Geschichte + UUID storyId = UUID.randomUUID(); + Geschichte story = Geschichte.builder() + .id(storyId) + .type(GeschichteType.STORY) + .build(); + when(geschichteQueryService.findById(storyId)).thenReturn(Optional.of(story)); + + // Act + Assert: calling append throws GESCHICHTE_TYPE_MISMATCH + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setDocumentId(UUID.randomUUID()); + assertThatThrownBy(() -> journeyItemService.append(storyId, dto)) + .isInstanceOf(DomainException.class); + + // Verify: document service was never touched — type guard fired first + verifyNoInteractions(documentService); + } + + @Test + void append_returns404_when_documentId_does_not_exist() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L); + when(documentService.findSummaryByIdInternal(docId)) + .thenThrow(DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "not found")); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setDocumentId(docId); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.DOCUMENT_NOT_FOUND)); + } + + @Test + void append_returns409_when_100_items_exist() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(100L); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setNote("Note"); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY)); + } + + @Test + void cap_is_COUNT_based_not_MAX_position_based() { + // 99 rows with MAX(position)=2000 should still accept the 100th append + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(99L); + when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(2000)); + JourneyItem saved = savedItem(itemId, journey, 2010, null, "Note"); + when(journeyItemRepository.save(any())).thenReturn(saved); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setNote("Note"); + + assertThat(journeyItemService.append(geschichteId, dto).position()).isEqualTo(2010); + } + + @Test + void append_audits_JOURNEY_ITEM_ADDED() { + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(0L); + when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.empty()); + JourneyItem saved = savedItem(itemId, journey, 10, null, "Note"); + when(journeyItemRepository.save(any())).thenReturn(saved); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setNote("Note"); + journeyItemService.append(geschichteId, dto); + + verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_ADDED), eq(actorId), isNull(), any()); + } + + // ─── updateNote ─────────────────────────────────────────────────────────── + + @Test + void updateNote_absent_leaves_note_unchanged() { + Geschichte journey = journey(geschichteId); + JourneyItem item = savedItem(itemId, journey, 10, null, "Original note"); + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); + + JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); + // note is null by default — absent from JSON, no-op + + JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto); + + assertThat(view.note()).isEqualTo("Original note"); + verify(journeyItemRepository, never()).save(any()); + } + + @Test + void updateNote_null_clears_note_when_document_is_present() { + Geschichte journey = journey(geschichteId); + Document doc = makeDoc(docId, null, List.of(), null, null); + JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old note"); + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); + JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null); + when(journeyItemRepository.save(item)).thenReturn(saved); + + JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); + dto.setNote(Optional.empty()); + + JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto); + + assertThat(view.note()).isNull(); + } + + @Test + void updateNote_string_sets_note() { + Geschichte journey = journey(geschichteId); + JourneyItem item = savedItem(itemId, journey, 10, null, null); + item.setNote(null); + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); + JourneyItem saved = savedItem(itemId, journey, 10, null, "New note"); + when(journeyItemRepository.save(item)).thenReturn(saved); + + JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); + dto.setNote(Optional.of("New note")); + + JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto); + + assertThat(view.note()).isEqualTo("New note"); + } + + @Test + void updateNote_null_returns400_when_item_has_no_document() { + Geschichte journey = journey(geschichteId); + JourneyItem item = savedItem(itemId, journey, 10, null, "Only note — no doc"); + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); + + JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); + dto.setNote(Optional.empty()); + + assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR)); + } + + @Test + void updateNote_whitespace_only_including_newlines_stored_as_null() { + Geschichte journey = journey(geschichteId); + Document doc = makeDoc(docId, null, List.of(), null, null); + JourneyItem item = savedItemWithDoc(itemId, journey, 10, doc, "Old"); + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); + JourneyItem saved = savedItemWithDoc(itemId, journey, 10, doc, null); + when(journeyItemRepository.save(item)).thenReturn(saved); + + JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); + dto.setNote(Optional.of("\n \n")); + + JourneyItemView view = journeyItemService.updateNote(geschichteId, itemId, dto); + + assertThat(view.note()).isNull(); + } + + @Test + void patch_returns400_when_note_exceeds_5000_chars() { + Geschichte journey = journey(geschichteId); + JourneyItem item = savedItem(itemId, journey, 10, null, "Old"); + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); + + JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); + dto.setNote(Optional.of("x".repeat(5001))); + + assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR)); + } + + @Test + void updateNote_auditsNoteUpdate() { + Geschichte journey = journey(geschichteId); + JourneyItem item = savedItem(itemId, journey, 10, null, null); + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); + JourneyItem saved = savedItem(itemId, journey, 10, null, "New note"); + when(journeyItemRepository.save(item)).thenReturn(saved); + + JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); + dto.setNote(Optional.of("New note")); + journeyItemService.updateNote(geschichteId, itemId, dto); + + verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_NOTE_UPDATED), eq(actorId), isNull(), any()); + } + + @Test + void patch_returns404_when_item_belongs_to_different_journey() { + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty()); + + JourneyItemUpdateDTO dto = new JourneyItemUpdateDTO(); + dto.setNote(Optional.of("text")); + + assertThatThrownBy(() -> journeyItemService.updateNote(geschichteId, itemId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND)); + } + + // ─── delete ─────────────────────────────────────────────────────────────── + + @Test + void delete_returns404_when_item_already_deleted() { + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.JOURNEY_ITEM_NOT_FOUND)); + } + + @Test + void delete_no_audit_when_item_not_found() { + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> journeyItemService.delete(geschichteId, itemId)) + .isInstanceOf(DomainException.class); + + verify(auditService, never()).logAfterCommit(any(), any(), any(), any()); + } + + @Test + void delete_audits_JOURNEY_ITEM_REMOVED_when_item_found() { + Geschichte journey = journey(geschichteId); + JourneyItem item = savedItem(itemId, journey, 10, null, "Note"); + when(journeyItemRepository.findByIdAndGeschichteId(itemId, geschichteId)).thenReturn(Optional.of(item)); + + journeyItemService.delete(geschichteId, itemId); + + verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEM_REMOVED), eq(actorId), isNull(), any()); + } + + // ─── reorder ───────────────────────────────────────────────────────────── + + @Test + void reorder_unknownGeschichteId_throws404() { + UUID unknownId = UUID.randomUUID(); + // geschichteQueryService is not lenient-stubbed for unknownId → returns false + + JourneyReorderDTO dto = new JourneyReorderDTO(); + dto.setItemIds(List.of()); + + assertThatThrownBy(() -> journeyItemService.reorder(unknownId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.GESCHICHTE_NOT_FOUND)); + } + + @Test + void reorder_returns400_when_itemIds_contain_duplicates() { + UUID id1 = UUID.randomUUID(); + when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1)); + + JourneyReorderDTO dto = new JourneyReorderDTO(); + dto.setItemIds(List.of(id1, id1)); // duplicate + + assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR)); + } + + @Test + void reorder_returns400_when_itemId_belongs_to_different_journey() { + UUID foreignId = UUID.randomUUID(); + UUID localId = UUID.randomUUID(); + when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(localId)); + + JourneyReorderDTO dto = new JourneyReorderDTO(); + dto.setItemIds(List.of(foreignId)); + + assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR)); + } + + @Test + void reorder_returns400_when_ids_have_extra_items() { + UUID id1 = UUID.randomUUID(); + UUID id2 = UUID.randomUUID(); + when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1)); + + JourneyReorderDTO dto = new JourneyReorderDTO(); + dto.setItemIds(List.of(id1, id2)); + + assertThatThrownBy(() -> journeyItemService.reorder(geschichteId, dto)) + .isInstanceOf(DomainException.class); + } + + @Test + void reorder_returns200_when_empty_on_empty_journey() { + when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of()); + + JourneyReorderDTO dto = new JourneyReorderDTO(); + dto.setItemIds(List.of()); + + List 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 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 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 ids = new java.util.ArrayList<>(); + List 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 views = journeyItemService.reorder(geschichteId, dto); + + assertThat(views).hasSize(130); + } + + @Test + void reorder_audits_JOURNEY_ITEMS_REORDERED() { + Geschichte journey = journey(geschichteId); + UUID id1 = UUID.randomUUID(); + JourneyItem item1 = savedItem(id1, journey, 10, null, "A"); + when(journeyItemRepository.findIdsByGeschichteId(geschichteId)).thenReturn(Set.of(id1)); + when(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).thenReturn(List.of(item1)); + when(journeyItemRepository.saveAll(any())).thenAnswer(inv -> inv.getArgument(0)); + + JourneyReorderDTO dto = new JourneyReorderDTO(); + dto.setItemIds(List.of(id1)); + journeyItemService.reorder(geschichteId, dto); + + verify(auditService).logAfterCommit(eq(AuditKind.JOURNEY_ITEMS_REORDERED), eq(actorId), isNull(), any()); + } + + // ─── helpers ───────────────────────────────────────────────────────────── + + private Geschichte journey(UUID id) { + return Geschichte.builder() + .id(id) + .title("Test Journey") + .type(GeschichteType.JOURNEY) + .status(GeschichteStatus.DRAFT) + .build(); + } + + private JourneyItem savedItem(UUID id, Geschichte g, int position, Document doc, String note) { + return JourneyItem.builder() + .id(id) + .geschichte(g) + .position(position) + .document(null) // no document entity to avoid LAZY issues in unit tests + .note(note) + .build(); + } + + private JourneyItem savedItemWithDoc(UUID id, Geschichte g, int position, Document doc, String note) { + JourneyItem item = JourneyItem.builder() + .id(id) + .geschichte(g) + .position(position) + .document(doc) + .note(note) + .build(); + return item; + } + + private Document makeDoc(UUID id, Person sender, List 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; + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b54eacf..540a9728 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,7 +38,7 @@ Both stacks are organised **package-by-domain**: each domain owns its entities, **`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events). -**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body). +**`geschichte`** — family stories and Lesereisen. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle) and `JourneyItem` (ordered stops in a JOURNEY-type Geschichte). Two subtypes: `STORY` (prose) and `JOURNEY` (curated document sequence). Cross-domain deps: `person` (linked persons), `document` (via `JourneyItem.document_id`, ON DELETE SET NULL). **`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context). diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index d7f05bb7..e38eb070 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -149,7 +149,17 @@ _See also [Chronik](#chronik-internal)._ **Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing). -**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission. +**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or curated document journey published in the archive. Two subtypes: `STORY` (free-form prose linking `Person`s) and `JOURNEY` (a *Lesereise* — an ordered sequence of `JourneyItem`s). Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission. + +**JourneyItem** (`JourneyItem`, table `journey_items`) `[internal]` — a single stop in a *Lesereise* (`Geschichte` with `type=JOURNEY`). Either document-backed (`document_id IS NOT NULL`) or a note-only interlude (`note IS NOT NULL`). Ordered by `position` (step of 10; max 100 items per journey). A DEFERRABLE UNIQUE constraint on `(geschichte_id, position)` allows atomic position swaps in the same transaction. A CHECK constraint ensures at least one of `document_id` or `note` is present. The FK to `documents` uses `ON DELETE SET NULL`, so deleting a document preserves the item (with `document_id = null`). + +**GeschichteView** (`GeschichteView`) `[internal]` — lean read-model record returned by `GeschichteService.getById()`. Contains `AuthorView` (id + displayName only — email not exposed) and a `List` loaded via a separate query rather than a lazy collection. + +**JourneyItemView** (`JourneyItemView`) `[internal]` — lean view record for a single `JourneyItem` surface, containing `id`, `position`, an optional `DocumentSummary`, and an optional `note`. + +**DocumentSummary** (`DocumentSummary`) `[internal]` — lean document read-model used inside `JourneyItemView`. Contains title, date, senderName, receiverName, receiverCount, datePrecision — no tags or file storage info. + +**Lesereise** `[user-facing]` — a curated reading journey through a sequence of family documents, optionally annotated with editorial notes. Implemented as a `Geschichte` with `type=JOURNEY`. The reader UI (follow-on issue) renders items as a sequential reading experience. **Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table. diff --git a/docs/adr/035-optional-string-three-way-patch-semantics.md b/docs/adr/035-optional-string-three-way-patch-semantics.md new file mode 100644 index 00000000..29979bff --- /dev/null +++ b/docs/adr/035-optional-string-three-way-patch-semantics.md @@ -0,0 +1,43 @@ +# ADR-035 — `Optional` 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` 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` with Java's default field initializer (`= null`) to encode the three states: + +```java +@Data +public class JourneyItemUpdateDTO { + private Optional 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`, 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. diff --git a/docs/architecture/c4/l3-backend-3g-supporting.puml b/docs/architecture/c4/l3-backend-3g-supporting.puml index c1558c89..fba5056b 100644 --- a/docs/architecture/c4/l3-backend-3g-supporting.puml +++ b/docs/architecture/c4/l3-backend-3g-supporting.puml @@ -16,8 +16,10 @@ System_Boundary(backend, "API Backend (Spring Boot)") { Component(notifCtrl, "NotificationController", "Spring MVC — /api/notifications", "REST and SSE endpoints for notification stream, history with filtering, read/unread state, and per-user preference management.") Component(notifSvc, "NotificationService", "Spring Service", "Creates REPLY and MENTION notifications, optionally sends email, marks as read, and pushes events to connected clients via SseEmitterRegistry.") Component(sseRegistry, "SseEmitterRegistry", "Spring Component", "In-memory ConcurrentHashMap of Spring SseEmitter instances per user. Handles registration, deregistration, and JSON event broadcasts.") - Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories that link persons and documents. Requires BLOG_WRITE permission for write operations.") - Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Sanitizes HTML body with an allowlist policy.") + Component(geschCtrl, "GeschichteController", "Spring MVC — /api/geschichten", "CRUD for publishable stories (STORY) and reading journeys (JOURNEY). Returns GeschichteSummary projections for list; full Geschichte with JourneyItems for detail. Requires BLOG_WRITE permission for write operations.") + Component(geschSvc, "GeschichteService", "Spring Service", "Manages story lifecycle (DRAFT → PUBLISHED with timestamp). Supports two subtypes: STORY (prose) and JOURNEY (ordered JourneyItem sequence). Sanitizes HTML body with an allowlist policy.") + Component(geschQuerySvc, "GeschichteQueryService", "Spring Service", "Read-only facade over GeschichteRepository. Exposes existsById() and findById() to prevent JourneyItemService from crossing domain boundaries.") + Component(journeyItemSvc, "JourneyItemService", "Spring Service", "Manages journey item lifecycle: append (100-item cap), updateNote (three-way PATCH), delete, and reorder (DEFERRABLE position swap). Enforces JOURNEY-type guard on append.") Component(exHandler, "GlobalExceptionHandler", "Spring @RestControllerAdvice", "Converts DomainException, validation errors, and generic exceptions to ErrorResponse JSON with machine-readable ErrorCode and HTTP status.") } @@ -38,6 +40,10 @@ Rel(notifCtrl, notifSvc, "Delegates to") Rel(notifCtrl, sseRegistry, "Registers client SSE connection") Rel(notifSvc, sseRegistry, "Broadcasts events to connected clients") Rel(geschCtrl, geschSvc, "Delegates to") +Rel(geschCtrl, journeyItemSvc, "Delegates journey item CRUD") +Rel(journeyItemSvc, geschQuerySvc, "Checks Geschichte existence and type") +Rel(geschQuerySvc, db, "Reads geschichten", "JDBC") +Rel(journeyItemSvc, db, "Reads / writes journey_items", "JDBC") Rel(auditSvc, db, "Writes audit_log", "JDBC") Rel(auditQuery, db, "Reads audit_log", "JDBC") Rel(notifSvc, db, "Reads / writes notifications", "JDBC") diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml index a73ecc4a..62cec50f 100644 --- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml +++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml @@ -11,8 +11,8 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(personEdit, "/persons/[id]/edit and /persons/new", "SvelteKit Routes", "Create and edit person forms. Edit: metadata, aliases, explicit relationships. Actions: PUT/POST /api/persons.") Component(personReview, "/persons/review", "SvelteKit Route", "Transcriber triage view (WRITE-gated link). Lists provisional persons; per-row Merge / Umbenennen / Bestätigen / Löschen. Actions: POST /merge, PUT /{id}, PATCH /{id}/confirm, DELETE /{id}.") Component(aktivitaeten, "/aktivitaeten", "SvelteKit Route", "Unified activity feed (Chronik). Loader: GET /api/dashboard/activity and GET /api/notifications?read=false.") - Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.") - Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.") + Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.") + Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (rich text editor, person linking, POST /api/geschichten) or JOURNEY placeholder (editor deferred to #753). Edit: PUT /api/geschichten/{id}. Requires BLOG_WRITE permission.") Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.") Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.") Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.") @@ -24,8 +24,8 @@ Rel(personsPage, backend, "GET /api/persons (filter + page params -> PersonSearc Rel(personEdit, backend, "GET /api/persons/{id}, PUT /api/persons/{id}, POST /api/persons", "HTTP / JSON") Rel(personReview, backend, "GET /api/persons?provisional=true, PATCH /api/persons/{id}/confirm, DELETE /api/persons/{id}, POST /api/persons/{id}/merge", "HTTP / JSON") Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications", "HTTP / JSON") -Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON") -Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON") +Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON") +Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}", "HTTP / JSON") Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON") Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON") Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON") diff --git a/docs/architecture/db/db-orm.puml b/docs/architecture/db/db-orm.puml index 48578048..a1ebd7d2 100644 --- a/docs/architecture/db/db-orm.puml +++ b/docs/architecture/db/db-orm.puml @@ -1,6 +1,6 @@ @startuml db-orm -' Schema source: Flyway V1–V69 (excl. V37, V43 — intentionally removed) -' Schema as of: V69 (2026-05-27) +' Schema source: Flyway V1–V72 (excl. V37, V43 — intentionally removed) +' Schema as of: V72 (2026-06-08) ' ⚠ This is a versioned snapshot. Update when the schema changes significantly. hide circle @@ -359,6 +359,7 @@ package "Supporting" { title : VARCHAR(255) NOT NULL body : TEXT status : VARCHAR(32) NOT NULL + type : VARCHAR(32) NOT NULL author_id : UUID <> created_at : TIMESTAMP NOT NULL updated_at : TIMESTAMP NOT NULL @@ -370,9 +371,15 @@ package "Supporting" { person_id : UUID <> } - entity geschichten_documents { + entity journey_items { + id : UUID <> + -- geschichte_id : UUID <> document_id : UUID <> + position : INTEGER NOT NULL CHECK (position > 0) + note : TEXT + == + UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED } } @@ -436,7 +443,7 @@ audit_log }o--o| documents : document_id geschichten }o--o| app_users : author_id geschichten_persons }o--|| geschichten : geschichte_id geschichten_persons }o--|| persons : person_id -geschichten_documents }o--|| geschichten : geschichte_id -geschichten_documents }o--|| documents : document_id +journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE) +journey_items }o--o| documents : document_id (ON DELETE SET NULL) @enduml diff --git a/docs/architecture/db/db-relationships.puml b/docs/architecture/db/db-relationships.puml index 82056761..571bc61f 100644 --- a/docs/architecture/db/db-relationships.puml +++ b/docs/architecture/db/db-relationships.puml @@ -66,7 +66,7 @@ package "Supporting" { entity audit_log entity geschichten entity geschichten_persons - entity geschichten_documents + entity journey_items } ' Auth relationships @@ -129,7 +129,7 @@ audit_log }o--o| documents : document_id geschichten }o--o| app_users : author_id geschichten_persons }o--|| geschichten : geschichte_id geschichten_persons }o--|| persons : person_id -geschichten_documents }o--|| geschichten : geschichte_id -geschichten_documents }o--|| documents : document_id +journey_items }o--|| geschichten : geschichte_id (ON DELETE CASCADE) +journey_items }o--o| documents : document_id (ON DELETE SET NULL) @enduml diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 32f6e464..99c9c1b7 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1023,6 +1023,11 @@ "nav_stammbaum": "Stammbaum", "nav_geschichten": "Geschichten", "error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.", + "error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.", + "error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.", + "error_journey_at_capacity": "Die Lesereise hat bereits die maximale Anzahl von Einträgen (100) erreicht.", + "error_geschichte_type_mismatch": "Diese Geschichte ist keine Lesereise – Reise-Einträge sind hier nicht erlaubt.", + "journey_item_document_deleted": "[Dokument gelöscht]", "geschichten_index_title": "Geschichten", "geschichten_new_button": "Neue Geschichte", "geschichten_filter_all_pill": "Alle", @@ -1037,6 +1042,7 @@ "geschichten_published_on": "veröffentlicht am {date}", "geschichten_persons_section": "Personen in dieser Geschichte", "geschichten_documents_section": "Erwähnte Dokumente", + "geschichten_document_link_placeholder": "Dokument öffnen", "geschichten_card_heading": "Geschichten", "geschichten_card_write_action": "+ Geschichte schreiben", "geschichten_card_attach_action": "+ Geschichte anhängen", @@ -1153,5 +1159,20 @@ "themen_alle": "Alle Themen", "themen_leer": "Noch keine Themen vergeben.", "themen_weitere": "+ {count} weitere", - "themen_dokumente": "{count} Dokumente" + "themen_dokumente": "{count} Dokumente", + "journey_badge_list": "REISE", + "journey_badge_detail": "LESEREISE", + "journey_selector_question": "Was möchtest du erstellen?", + "journey_selector_story_title": "Geschichte", + "journey_selector_story_desc": "Eine erzählte Geschichte mit Bildern und Text.", + "journey_selector_journey_title": "Lesereise", + "journey_selector_journey_desc": "Eine kuratierte Auswahl von Briefen mit Notizen.", + "journey_selector_next_btn": "Weiter", + "journey_placeholder_back": "andere Auswahl", + "journey_placeholder_heading": "Lesereise-Editor folgt in #753", + "journey_item_open_aria": "Brief vom {date} öffnen", + "journey_item_open_aria_undated": "Brief öffnen", + "journey_empty_state": "Diese Lesereise ist noch leer.", + "journey_interlude_aria_label": "Kuratorennotiz", + "journey_selector_aria_live_hint": "Bitte wähle einen Typ aus, um fortzufahren." } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 435860e1..0c4686a4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1023,6 +1023,11 @@ "nav_stammbaum": "Family tree", "nav_geschichten": "Stories", "error_geschichte_not_found": "The story was not found.", + "error_journey_item_not_found": "The journey item was not found.", + "error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.", + "error_journey_at_capacity": "The reading journey has already reached the maximum of 100 items.", + "error_geschichte_type_mismatch": "This story is not a reading journey — journey items are not allowed here.", + "journey_item_document_deleted": "[Document deleted]", "geschichten_index_title": "Stories", "geschichten_new_button": "New story", "geschichten_filter_all_pill": "All", @@ -1037,6 +1042,7 @@ "geschichten_published_on": "published on {date}", "geschichten_persons_section": "People in this story", "geschichten_documents_section": "Referenced documents", + "geschichten_document_link_placeholder": "Open document", "geschichten_card_heading": "Stories", "geschichten_card_write_action": "+ Write a story", "geschichten_card_attach_action": "+ Attach a story", @@ -1153,5 +1159,20 @@ "themen_alle": "All Topics", "themen_leer": "No topics assigned yet.", "themen_weitere": "+ {count} more", - "themen_dokumente": "{count} documents" + "themen_dokumente": "{count} documents", + "journey_badge_list": "JOURNEY", + "journey_badge_detail": "READING JOURNEY", + "journey_selector_question": "What would you like to create?", + "journey_selector_story_title": "Story", + "journey_selector_story_desc": "A narrative story with images and text.", + "journey_selector_journey_title": "Reading Journey", + "journey_selector_journey_desc": "A curated selection of letters with notes.", + "journey_selector_next_btn": "Continue", + "journey_placeholder_back": "different selection", + "journey_placeholder_heading": "Reading Journey editor coming in #753", + "journey_item_open_aria": "Open letter from {date}", + "journey_item_open_aria_undated": "Open letter", + "journey_empty_state": "This reading journey is still empty.", + "journey_interlude_aria_label": "Curator's note", + "journey_selector_aria_live_hint": "Please select a type to continue." } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 88e2affb..3c8a361e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1023,6 +1023,11 @@ "nav_stammbaum": "Árbol genealógico", "nav_geschichten": "Historias", "error_geschichte_not_found": "No se encontró la historia.", + "error_journey_item_not_found": "No se encontró el elemento del viaje.", + "error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.", + "error_journey_at_capacity": "El viaje de lectura ya ha alcanzado el máximo de 100 entradas.", + "error_geschichte_type_mismatch": "Esta historia no es un viaje de lectura — los elementos de viaje no están permitidos aquí.", + "journey_item_document_deleted": "[Documento eliminado]", "geschichten_index_title": "Historias", "geschichten_new_button": "Nueva historia", "geschichten_filter_all_pill": "Todas", @@ -1037,6 +1042,7 @@ "geschichten_published_on": "publicada el {date}", "geschichten_persons_section": "Personas en esta historia", "geschichten_documents_section": "Documentos mencionados", + "geschichten_document_link_placeholder": "Abrir documento", "geschichten_card_heading": "Historias", "geschichten_card_write_action": "+ Escribir historia", "geschichten_card_attach_action": "+ Adjuntar historia", @@ -1153,5 +1159,20 @@ "themen_alle": "Todos los temas", "themen_leer": "Aún no hay temas.", "themen_weitere": "+ {count} más", - "themen_dokumente": "{count} documentos" + "themen_dokumente": "{count} documentos", + "journey_badge_list": "VIAJE", + "journey_badge_detail": "VIAJE DE LECTURA", + "journey_selector_question": "¿Qué deseas crear?", + "journey_selector_story_title": "Historia", + "journey_selector_story_desc": "Una historia narrada con imágenes y texto.", + "journey_selector_journey_title": "Viaje de lectura", + "journey_selector_journey_desc": "Una selección curada de cartas con notas.", + "journey_selector_next_btn": "Continuar", + "journey_placeholder_back": "otra selección", + "journey_placeholder_heading": "Editor de viaje de lectura próximamente en #753", + "journey_item_open_aria": "Abrir carta del {date}", + "journey_item_open_aria_undated": "Abrir carta", + "journey_empty_state": "Este viaje de lectura está vacío.", + "journey_interlude_aria_label": "Nota del curador", + "journey_selector_aria_live_hint": "Por favor, selecciona un tipo para continuar." } diff --git a/frontend/src/lib/document/DashboardNeedsMetadata.svelte.spec.ts b/frontend/src/lib/document/DashboardNeedsMetadata.svelte.spec.ts index b08e32ff..f842f296 100644 --- a/frontend/src/lib/document/DashboardNeedsMetadata.svelte.spec.ts +++ b/frontend/src/lib/document/DashboardNeedsMetadata.svelte.spec.ts @@ -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(); }); }); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index efe723b4..ceb29071 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -84,6 +84,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/geschichten/{id}/items/reorder": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Reorder journey items + * @description itemIds must contain ALL item IDs for the given journey in the desired new order. Sending a partial list returns 400 Bad Request. + */ + put: operations["reorderItems"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{id}": { parameters: { query?: never; @@ -420,6 +440,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/geschichten/{id}/items": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appendItem"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents": { parameters: { query?: never; @@ -692,22 +728,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/admin/backfill-titles": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post: operations["backfillTitles"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/admin/backfill-file-hashes": { parameters: { query?: never; @@ -804,6 +824,22 @@ export interface paths { patch: operations["update"]; trace?: never; }; + "/api/geschichten/{id}/items/{itemId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteItem"]; + options?: never; + head?: never; + patch: operations["updateItemNote"]; + trace?: never; + }; "/api/documents/{id}/training-labels": { parameters: { query?: never; @@ -875,7 +911,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_1"]; + get: operations["search"]; put?: never; post?: never; delete?: never; @@ -1339,7 +1375,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_2"]; + get: operations["search_1"]; put?: never; post?: never; delete?: never; @@ -1428,6 +1464,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/conversation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getConversation"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/dashboard/resume": { parameters: { query?: never; @@ -1690,6 +1742,32 @@ export interface components { provisional: boolean; readonly displayName: string; }; + JourneyReorderDTO: { + itemIds?: string[]; + }; + DocumentSummary: { + /** Format: uuid */ + id: string; + title: string; + /** Format: date */ + documentDate?: string; + /** Format: date */ + documentDateEnd?: string; + /** @enum {string} */ + datePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + senderName?: string; + receiverName?: string; + /** Format: int32 */ + receiverCount: number; + }; + JourneyItemView: { + /** Format: uuid */ + id: string; + /** Format: int32 */ + position: number; + document?: components["schemas"]["DocumentSummary"]; + note?: string; + }; DocumentUpdateDTO: { title?: string; /** Format: date */ @@ -1758,7 +1836,6 @@ export interface components { sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; - hasTranscription: boolean; thumbnailUrl?: string; }; PersonMention: { @@ -1819,75 +1896,6 @@ export interface components { /** Format: uuid */ targetId: string; }; - Pageable: { - /** Format: int32 */ - page?: number; - /** Format: int32 */ - size?: number; - sort?: string[]; - }; - ActivityActorDTO: { - initials: string; - color: string; - name?: string; - }; - DocumentListItem: { - /** Format: uuid */ - id: string; - title: string; - originalFilename: string; - thumbnailUrl?: string; - /** Format: date */ - documentDate?: string; - /** @enum {string} */ - metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; - /** Format: date */ - metaDateEnd?: string; - sender?: components["schemas"]["Person"]; - receivers: components["schemas"]["Person"][]; - tags: components["schemas"]["Tag"][]; - archiveBox?: string; - archiveFolder?: string; - location?: string; - summary?: string; - /** Format: int32 */ - completionPercentage: number; - contributors: components["schemas"]["ActivityActorDTO"][]; - matchData: components["schemas"]["SearchMatchData"]; - /** Format: date-time */ - createdAt: string; - /** Format: date-time */ - updatedAt: string; - }; - DocumentSearchResult: { - items: components["schemas"]["DocumentListItem"][]; - /** Format: int64 */ - totalElements: number; - /** Format: int32 */ - pageNumber: number; - /** Format: int32 */ - pageSize: number; - /** Format: int32 */ - totalPages: number; - /** Format: int64 */ - undatedCount: number; - }; - MatchOffset: { - /** Format: int32 */ - start: number; - /** Format: int32 */ - length: number; - }; - SearchMatchData: { - transcriptionSnippet?: string; - titleOffsets: components["schemas"]["MatchOffset"][]; - senderMatched: boolean; - matchedReceiverIds: string[]; - matchedTagIds: string[]; - snippetOffsets: components["schemas"]["MatchOffset"][]; - summarySnippet?: string; - summaryOffsets: components["schemas"]["MatchOffset"][]; - }; CreateRelationshipRequest: { /** Format: uuid */ relatedPersonId: string; @@ -2233,6 +2241,9 @@ export interface components { actorName?: string; documentTitle?: string; }; + JourneyItemUpdateDTO: { + note?: string; + }; TrainingLabelRequest: { label?: string; enrolled?: boolean; @@ -2273,6 +2284,11 @@ export interface components { /** Format: int64 */ transcriptionCount: number; }; + ActivityActorDTO: { + initials: string; + color: string; + name?: string; + }; TranscriptionQueueItemDTO: { /** Format: uuid */ id: string; @@ -2295,11 +2311,6 @@ export interface components { color?: string; /** Format: int32 */ documentCount: number; - /** - * Format: int32 - * @description Distinct documents tagged with this tag or any descendant tag (subtree rollup) - */ - subtreeDocumentCount: number; children?: components["schemas"]["TagTreeNodeDTO"][]; /** * Format: uuid @@ -2335,13 +2346,13 @@ export interface components { lastName?: string; /** Format: int64 */ documentCount?: number; + alias?: string; notes?: string; /** Format: int32 */ birthYear?: number; /** Format: int32 */ deathYear?: number; provisional?: boolean; - alias?: string; personType?: string; familyMember?: boolean; }; @@ -2440,6 +2451,8 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -2448,8 +2461,6 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; - first?: boolean; - last?: boolean; empty?: boolean; }; PageableObject: { @@ -2472,6 +2483,54 @@ export interface components { nodes: components["schemas"]["PersonNodeDTO"][]; edges: components["schemas"]["RelationshipDTO"][]; }; + AuthorSummary: { + firstName?: string; + lastName?: string; + email: string; + }; + GeschichteSummary: { + body?: string; + title: string; + /** Format: uuid */ + id: string; + /** @enum {string} */ + type: "STORY" | "JOURNEY"; + /** @enum {string} */ + status: "DRAFT" | "PUBLISHED"; + author?: components["schemas"]["AuthorSummary"]; + /** Format: date-time */ + publishedAt?: string; + }; + AuthorView: { + /** Format: uuid */ + id: string; + displayName: string; + }; + GeschichteView: { + /** Format: uuid */ + id: string; + title: string; + body?: string; + /** @enum {string} */ + status: "DRAFT" | "PUBLISHED"; + /** @enum {string} */ + type: "STORY" | "JOURNEY"; + author?: components["schemas"]["AuthorView"]; + persons: components["schemas"]["PersonView"][]; + items: components["schemas"]["JourneyItemView"][]; + /** Format: date-time */ + publishedAt?: string; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + PersonView: { + /** Format: uuid */ + id: string; + firstName?: string; + lastName?: string; + }; DocumentVersionSummary: { /** Format: uuid */ id: string; @@ -2513,6 +2572,63 @@ export interface components { /** Format: int32 */ totalPages?: number; }; + DocumentListItem: { + /** Format: uuid */ + id: string; + title: string; + originalFilename: string; + thumbnailUrl?: string; + /** Format: date */ + documentDate?: string; + /** @enum {string} */ + metaDatePrecision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN"; + /** Format: date */ + metaDateEnd?: string; + sender?: components["schemas"]["Person"]; + receivers: components["schemas"]["Person"][]; + tags: components["schemas"]["Tag"][]; + archiveBox?: string; + archiveFolder?: string; + location?: string; + summary?: string; + /** Format: int32 */ + completionPercentage: number; + contributors: components["schemas"]["ActivityActorDTO"][]; + matchData: components["schemas"]["SearchMatchData"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; + }; + DocumentSearchResult: { + items: components["schemas"]["DocumentListItem"][]; + /** Format: int64 */ + totalElements: number; + /** Format: int32 */ + pageNumber: number; + /** Format: int32 */ + pageSize: number; + /** Format: int32 */ + totalPages: number; + /** Format: int64 */ + undatedCount: number; + }; + MatchOffset: { + /** Format: int32 */ + start: number; + /** Format: int32 */ + length: number; + }; + SearchMatchData: { + transcriptionSnippet?: string; + titleOffsets: components["schemas"]["MatchOffset"][]; + senderMatched: boolean; + matchedReceiverIds: string[]; + matchedTagIds: string[]; + snippetOffsets: components["schemas"]["MatchOffset"][]; + summarySnippet?: string; + summaryOffsets: components["schemas"]["MatchOffset"][]; + }; IncompleteDocumentDTO: { /** Format: uuid */ id: string; @@ -2561,7 +2677,7 @@ export interface components { }; ActivityFeedItemDTO: { /** @enum {string} */ - kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED"; + kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED"; actor?: components["schemas"]["ActivityActorDTO"]; /** Format: uuid */ documentId: string; @@ -2871,6 +2987,32 @@ export interface operations { }; }; }; + reorderItems: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JourneyReorderDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["JourneyItemView"][]; + }; + }; + }; + }; getDocument: { parameters: { query?: never; @@ -3620,6 +3762,32 @@ export interface operations { }; }; }; + appendItem: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JourneyItemCreateDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["JourneyItemView"]; + }; + }; + }; + }; createDocument: { parameters: { query?: never; @@ -4118,26 +4286,6 @@ export interface operations { }; }; }; - backfillTitles: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["BackfillResult"]; - }; - }; - }; - }; backfillFileHashes: { parameters: { query?: never; @@ -4291,7 +4439,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["Geschichte"]; + "*/*": components["schemas"]["GeschichteView"]; }; }; }; @@ -4342,6 +4490,54 @@ export interface operations { }; }; }; + deleteItem: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + itemId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateItemNote: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + itemId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["JourneyItemUpdateDTO"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["JourneyItemView"]; + }; + }; + }; + }; patchTrainingLabel: { parameters: { query?: never; @@ -4486,7 +4682,7 @@ export interface operations { }; }; }; - search_1: { + search: { parameters: { query?: { q?: string; @@ -5110,7 +5306,7 @@ export interface operations { }; }; }; - search_2: { + search_1: { parameters: { query?: { q?: string; @@ -5280,6 +5476,32 @@ export interface operations { }; }; }; + getConversation: { + parameters: { + query: { + senderId: string; + receiverId?: string; + from?: string; + to?: string; + dir?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Document"][]; + }; + }; + }; + }; getResume: { parameters: { query?: never; @@ -5325,7 +5547,7 @@ export interface operations { query?: { limit?: number; /** @description Filter by audit kinds; omit for all rollup-eligible kinds */ - kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED")[]; + kinds?: ("FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED" | "USER_CREATED" | "USER_DELETED" | "GROUP_MEMBERSHIP_CHANGED" | "LOGIN_SUCCESS" | "LOGIN_FAILED" | "LOGOUT" | "ADMIN_FORCE_LOGOUT" | "LOGIN_RATE_LIMITED" | "JOURNEY_ITEM_ADDED" | "JOURNEY_ITEM_REMOVED" | "JOURNEY_ITEM_NOTE_UPDATED" | "JOURNEY_ITEMS_REORDERED")[]; }; header?: never; path?: never; diff --git a/frontend/src/lib/geschichte/GeschichteEditor.svelte b/frontend/src/lib/geschichte/GeschichteEditor.svelte index 1df78c03..1448b73c 100644 --- a/frontend/src/lib/geschichte/GeschichteEditor.svelte +++ b/frontend/src/lib/geschichte/GeschichteEditor.svelte @@ -6,33 +6,23 @@ import StarterKit from '@tiptap/starter-kit'; import { m } from '$lib/paraglide/messages.js'; import type { components } from '$lib/generated/api'; import PersonMultiSelect from '$lib/person/PersonMultiSelect.svelte'; -import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte'; type Geschichte = components['schemas']['Geschichte']; type Person = components['schemas']['Person']; -type Document = components['schemas']['Document']; interface Props { geschichte?: Geschichte | null; initialPersons?: Person[]; - initialDocuments?: Document[]; onSubmit: (payload: { title: string; body: string; status: 'DRAFT' | 'PUBLISHED'; personIds: string[]; - documentIds: string[]; }) => Promise; submitting?: boolean; } -let { - geschichte = null, - initialPersons = [], - initialDocuments = [], - onSubmit, - submitting = false -}: Props = $props(); +let { geschichte = null, initialPersons = [], onSubmit, submitting = false }: Props = $props(); // Initial-state snapshot from incoming props. The editor owns these values // after mount; the parent should re-mount the component with a different @@ -44,9 +34,6 @@ let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT'); let selectedPersons: Person[] = $state( geschichte?.persons ? Array.from(geschichte.persons) : initialPersons ); -let selectedDocuments: Document[] = $state( - geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments -); let dirty = $state(false); let titleTouched = $state(false); @@ -122,8 +109,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') { title: title.trim(), body, status: nextStatus, - personIds: selectedPersons.map((p) => p.id!).filter(Boolean), - documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean) + personIds: selectedPersons.map((p) => p.id!).filter(Boolean) }); dirty = false; } @@ -269,14 +255,6 @@ function exec(action: () => void) {

{m.geschichte_editor_personen_hint()}

- -
-

- {m.geschichte_editor_dokumente_heading()} -

-

{m.geschichte_editor_dokumente_hint()}

- -
diff --git a/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts index 3593af8e..e40a6b31 100644 --- a/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichteEditor.svelte.spec.ts @@ -8,19 +8,9 @@ const personFactory = (id: string, displayName: string) => ({ firstName: displayName.split(' ')[0], lastName: displayName.split(' ').slice(1).join(' ') || displayName, displayName, - personType: 'PERSON' as const -}); - -const docFactory = (id: string, title: string, date = '1882-01-01') => ({ - id, - title, - documentDate: date, - originalFilename: `${title}.pdf`, - status: 'UPLOADED' as const, - metadataComplete: false, - scriptType: 'UNKNOWN' as const, - createdAt: '2024-01-01T00:00:00', - updatedAt: '2024-01-01T00:00:00' + personType: 'PERSON' as const, + familyMember: false, + provisional: false }); const draftFactory = (overrides: Record = {}) => ({ @@ -28,8 +18,9 @@ const draftFactory = (overrides: Record = {}) => ({ title: 'Existing draft', body: '

Hello world

', status: 'DRAFT' as const, + type: 'STORY' as const, persons: [], - documents: [], + items: [], createdAt: '2024-01-01T00:00:00', updatedAt: '2024-01-01T00:00:00', ...overrides @@ -93,14 +84,6 @@ describe('GeschichteEditor — pre-fill', () => { await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument(); }); - it('renders initial documents as chips', async () => { - render(GeschichteEditor, { - initialDocuments: [docFactory('d1', 'Brief von Eugenie')], - onSubmit: vi.fn() - }); - await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument(); - }); - it('populates the title input from a geschichte prop', async () => { render(GeschichteEditor, { geschichte: draftFactory({ title: 'My existing story' }), @@ -154,11 +137,10 @@ describe('GeschichteEditor — onSubmit payload', () => { expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED'); }); - it('passes the personIds and documentIds from initial props through onSubmit', async () => { + it('passes personIds from initial props through onSubmit', async () => { const onSubmit = vi.fn().mockResolvedValue(undefined); render(GeschichteEditor, { initialPersons: [personFactory('p1', 'Franz Raddatz')], - initialDocuments: [docFactory('d1', 'Brief A')], onSubmit }); @@ -171,6 +153,5 @@ describe('GeschichteEditor — onSubmit payload', () => { expect(onSubmit).toHaveBeenCalledTimes(1); const payload = onSubmit.mock.calls[0][0]; expect(payload.personIds).toEqual(['p1']); - expect(payload.documentIds).toEqual(['d1']); }); }); diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte b/frontend/src/lib/geschichte/GeschichteListRow.svelte new file mode 100644 index 00000000..68d8d8e2 --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte @@ -0,0 +1,42 @@ + + + +
+

{geschichte.title}

+ {#if isJourney} + + {m.journey_badge_list()} + + {/if} +
+

+ {authorName} + {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if} +

+ {#if geschichte.body} + +

{plainExcerpt(geschichte.body, 150)}

+ {/if} +
diff --git a/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts new file mode 100644 index 00000000..c0aa748d --- /dev/null +++ b/frontend/src/lib/geschichte/GeschichteListRow.svelte.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +const { default: GeschichteListRow } = await import('./GeschichteListRow.svelte'); + +afterEach(cleanup); + +const baseRow = (overrides = {}) => ({ + id: 'g1', + title: 'Die Reise nach Berlin', + body: '

Im Jahr 1923...

', + type: 'STORY' as 'STORY' | 'JOURNEY', + status: 'PUBLISHED' as 'PUBLISHED' | 'DRAFT', + author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' }, + publishedAt: '2026-04-15T10:00:00Z', + ...overrides +}); + +describe('GeschichteListRow', () => { + it('renders the title', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + await expect + .element(page.getByRole('heading', { level: 2 })) + .toHaveTextContent('Die Reise nach Berlin'); + }); + + it('shows no badge for STORY type', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'STORY' }) } }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + + it('shows no badge when type is undefined', async () => { + render(GeschichteListRow, { + props: { geschichte: baseRow({ type: undefined as unknown as 'STORY' | 'JOURNEY' }) } + }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + + it('shows REISE badge for JOURNEY type', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge).not.toBeNull(); + expect(badge?.textContent?.trim()).toBe('REISE'); + }); + + it('badge is a plain , not a nested interactive element', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge?.tagName.toLowerCase()).toBe('span'); + }); + + it('badge has small font size appropriate for a label', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow({ type: 'JOURNEY' }) } }); + const badge = document.querySelector('[data-testid="journey-badge"]'); + const fontSize = parseFloat(window.getComputedStyle(badge!).fontSize); + expect(fontSize).toBeGreaterThan(0); + expect(fontSize).toBeLessThanOrEqual(14); // label badge must not exceed body text size + }); + + it('renders author name in meta line', async () => { + render(GeschichteListRow, { props: { geschichte: baseRow() } }); + expect(document.body.textContent).toContain('Anna Schmidt'); + }); +}); diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte b/frontend/src/lib/geschichte/GeschichtenCard.svelte index 2fec7b39..f7f71166 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte @@ -4,10 +4,10 @@ import type { components } from '$lib/generated/api'; import { plainExcerpt } from '$lib/shared/utils/extractText'; import { formatDate } from '$lib/shared/utils/date'; -type Geschichte = components['schemas']['Geschichte']; +type GeschichteSummary = components['schemas']['GeschichteSummary']; interface Props { - geschichten: Geschichte[]; + geschichten: GeschichteSummary[]; personId: string; personName: string; canWrite: boolean; @@ -18,12 +18,12 @@ let { geschichten, personId, personName, canWrite }: Props = $props(); const visible = $derived(geschichten.slice(0, 3)); const hasOverflow = $derived(geschichten.length >= 3); -function formatPublishedDate(g: Geschichte): string | null { +function formatPublishedDate(g: GeschichteSummary): string | null { if (!g.publishedAt) return null; return formatDate(g.publishedAt.slice(0, 10), 'short'); } -function authorName(g: Geschichte): string { +function authorName(g: GeschichteSummary): string { const a = g.author; if (!a) return ''; const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim(); diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts index c73f66d9..2aeefc5a 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.spec.ts @@ -3,16 +3,17 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import GeschichtenCard from './GeschichtenCard.svelte'; -const makeStory = (id: string, title: string, body: string | null = '

Body

') => ({ +const makeStory = (id: string, title: string, body: string | undefined = '

Body

') => ({ id, title, body, status: 'PUBLISHED' as const, + type: 'STORY' as const, publishedAt: '2024-04-01T12:00:00', createdAt: '2024-03-01T12:00:00', updatedAt: '2024-04-01T12:00:00', persons: [], - documents: [], + items: [], author: { id: 'u1', email: 'marcel@example.com', @@ -120,6 +121,16 @@ describe('GeschichtenCard', () => { expect(link.getAttribute('href')).toBe('/geschichten?personId=p1'); }); + it('JOURNEY type does not bleed a REISE badge into the person-sidebar card', async () => { + render(GeschichtenCard, { + geschichten: [{ ...makeStory('g1', 'Reise Berlin'), type: 'JOURNEY' as const }], + personId: 'p1', + personName: 'Franz', + canWrite: false + }); + expect(document.querySelector('[data-testid="journey-badge"]')).toBeNull(); + }); + it('renders a plain-text excerpt without HTML markup', async () => { render(GeschichtenCard, { geschichten: [ diff --git a/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts b/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts index e3212cbf..9b46a7d8 100644 --- a/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts +++ b/frontend/src/lib/geschichte/GeschichtenCard.svelte.test.ts @@ -2,20 +2,30 @@ import { describe, it, expect, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import GeschichtenCard from './GeschichtenCard.svelte'; +import type { components } from '$lib/generated/api'; + +type GeschichteSummary = components['schemas']['GeschichteSummary']; afterEach(cleanup); -const makeGeschichte = (overrides: Record = {}) => ({ - id: 'g1', - title: 'Reise nach Berlin', - body: '

Brief text

', - publishedAt: '2026-04-15T10:00:00Z', - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'a@b' } as unknown, - ...overrides -}); +const makeGeschichte = (overrides: Record = {}): GeschichteSummary => + ({ + id: 'g1', + title: 'Reise nach Berlin', + body: '

Brief text

', + status: 'PUBLISHED' as const, + type: 'STORY' as const, + publishedAt: '2026-04-15T10:00:00Z', + author: { + email: 'a@b', + firstName: 'Anna', + lastName: 'Schmidt' + }, + ...overrides + }) as GeschichteSummary; const baseProps = (overrides: Record = {}) => ({ - geschichten: [] as ReturnType[], + geschichten: [] as GeschichteSummary[], personId: 'p-1', personName: 'Anna Schmidt', canWrite: false, diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte b/frontend/src/lib/geschichte/JourneyInterlude.svelte new file mode 100644 index 00000000..9bfcca9e --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte @@ -0,0 +1,24 @@ + + +
+ + +

{note}

+
diff --git a/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts new file mode 100644 index 00000000..2fc28ec0 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyInterlude.svelte.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; + +const { default: JourneyInterlude } = await import('./JourneyInterlude.svelte'); + +afterEach(cleanup); + +declare global { + interface Window { + __xss_interlude?: number; + } +} + +describe('JourneyInterlude', () => { + it('renders the note text as plaintext', async () => { + render(JourneyInterlude, { props: { note: 'Eine kurze Pause auf der Reise.' } }); + + await expect.element(page.getByText('Eine kurze Pause auf der Reise.')).toBeVisible(); + }); + + it('has aria-label from i18n (journey_interlude_aria_label)', async () => { + render(JourneyInterlude, { props: { note: 'Notiz' } }); + + const el = document.querySelector(`[aria-label="${m.journey_interlude_aria_label()}"]`); + expect(el).not.toBeNull(); + }); + + it('has role="note" so the aria-label is announced by screen readers', async () => { + render(JourneyInterlude, { props: { note: 'Notiz' } }); + + const el = document.querySelector('[role="note"]'); + expect(el).not.toBeNull(); + expect(el?.getAttribute('aria-label')).toBe(m.journey_interlude_aria_label()); + }); + + it('renders the section-break glyph ❦', async () => { + render(JourneyInterlude, { props: { note: 'Notiz' } }); + + expect(document.body.textContent).toContain('❦'); + }); + + it('XSS: note is rendered as plaintext — injected payload does not execute', async () => { + // Interlude uses Svelte text interpolation ({note}), NOT {@html}. + render(JourneyInterlude, { + props: { note: '' } + }); + + expect(window.__xss_interlude).toBeUndefined(); + expect(document.body.textContent).toContain(' 0); + + + + {doc.title} + {#if formattedDate} + {formattedDate} + {/if} + + +{#if hasNote} + +

+ + {item.note} +

+{/if} diff --git a/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts new file mode 100644 index 00000000..75a77516 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemCard.svelte.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import type { components } from '$lib/generated/api'; + +const { default: JourneyItemCard } = await import('./JourneyItemCard.svelte'); + +afterEach(cleanup); + +declare global { + interface Window { + __xss_note?: number; + } +} + +type JourneyItemView = components['schemas']['JourneyItemView']; + +const baseItem = (overrides: Partial = {}): JourneyItemView => ({ + id: 'item1', + position: 0, + document: { + id: 'd1', + title: 'Brief an Helene', + documentDate: '1923-05-15', + datePrecision: 'FULL' + }, + ...overrides +}); + +describe('JourneyItemCard', () => { + it('renders the document title', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + await expect.element(page.getByText('Brief an Helene')).toBeVisible(); + }); + + it('renders the document date when documentDate is present', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + await expect.element(page.getByText(/1923/)).toBeVisible(); + }); + + it('whole card is a single element', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + const link = document.querySelector('a'); + expect(link).not.toBeNull(); + expect(link?.href).toContain('/documents/d1'); + }); + + it('link has dated aria-label when documentDate is present', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + const link = document.querySelector('a'); + expect(link?.getAttribute('aria-label')).toContain('Brief'); + expect(link?.getAttribute('aria-label')).toContain('1923'); + }); + + it('link has undated aria-label when documentDate is absent', async () => { + render(JourneyItemCard, { + props: { + item: baseItem({ + document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' } + }) + } + }); + + const link = document.querySelector('a'); + expect(link?.getAttribute('aria-label')).toBe('Brief öffnen'); + }); + + it('omits date text when documentDate is absent', async () => { + render(JourneyItemCard, { + props: { + item: baseItem({ + document: { id: 'd2', title: 'Ohne Datum', datePrecision: 'NONE' } + }) + } + }); + + await expect.element(page.getByText(/1923/)).not.toBeInTheDocument(); + }); + + it('renders ✎ glyph and note text when note is present', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: 'Ein wichtiger Brief' }) } }); + + expect(document.body.textContent).toContain('✎'); + await expect.element(page.getByText('Ein wichtiger Brief')).toBeVisible(); + }); + + it('omits annotation block when note is blank or whitespace', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: ' ' }) } }); + + expect(document.body.textContent).not.toContain('✎'); + }); + + it('omits annotation block when note is absent', async () => { + render(JourneyItemCard, { props: { item: baseItem({ note: undefined }) } }); + + expect(document.body.textContent).not.toContain('✎'); + }); + + it('link meets 44px touch-target minimum height', async () => { + render(JourneyItemCard, { props: { item: baseItem() } }); + + const link = document.querySelector('a'); + const rect = link?.getBoundingClientRect(); + expect(rect?.height).toBeGreaterThanOrEqual(44); + }); + + it('XSS: note is rendered as plaintext — injected payload does not execute', async () => { + // Note uses Svelte text interpolation ({note}), NOT {@html}. + render(JourneyItemCard, { + props: { + item: baseItem({ + note: '' + }) + } + }); + + expect(window.__xss_note).toBeUndefined(); + expect(document.body.textContent).toContain(' Promise; +} + +let { geschichte: g, canBlogWrite, ondelete }: Props = $props(); + +// Render intro only when body is a non-empty, non-whitespace string. +const introText = $derived(g.body?.trim() ? g.body : null); + +// Omit items that have neither a document nor a non-blank note (dangling deleted-document guard). +const validItems = $derived( + g.items.filter( + (item: JourneyItemView) => + item.document != null || (item.note != null && item.note.trim().length > 0) + ) +); + + +{#if introText} + +

{introText}

+{/if} + +{#if validItems.length === 0} +

+ {m.journey_empty_state()} +

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

+ {m.geschichten_persons_section()} +

+ +
+{/if} + + +{#if g.items && g.items.some((i) => i.document)} +
+

+ {m.geschichten_documents_section()} +

+ +
+{/if} + + +{#if canBlogWrite} +
+ + {m.btn_edit()} + + +
+{/if} diff --git a/frontend/src/lib/geschichte/StoryReader.svelte.spec.ts b/frontend/src/lib/geschichte/StoryReader.svelte.spec.ts new file mode 100644 index 00000000..6bd62875 --- /dev/null +++ b/frontend/src/lib/geschichte/StoryReader.svelte.spec.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; +import type { components } from '$lib/generated/api'; + +const { default: StoryReader } = await import('./StoryReader.svelte'); + +afterEach(cleanup); + +type GeschichteView = components['schemas']['GeschichteView']; + +const baseGeschichte = (overrides: Partial = {}): GeschichteView => ({ + id: 'g1', + title: 'Die Reise nach Berlin', + body: '

Im Jahr 1923 fuhr Helene...

', + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides +}); + +const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]); + +describe('StoryReader', () => { + it('renders body HTML content', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: false } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + }); + + it('omits persons section when persons array is empty', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ persons: [] }), canBlogWrite: false } + }); + + await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument(); + }); + + it('renders persons section with firstName + lastName joined', async () => { + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + persons: [ + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } + ] + }), + canBlogWrite: false + } + }); + + await expect.element(page.getByText('Personen in dieser Geschichte')).toBeVisible(); + await expect.element(page.getByText('Helene Schmidt')).toBeVisible(); + await expect.element(page.getByText('Karl Müller')).toBeVisible(); + }); + + it('omits documents section when no items have documents', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false } + }); + + await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument(); + }); + + it('renders documents section for items with documents', async () => { + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + items: [ + { + id: 'i1', + position: 0, + document: { id: 'd1', title: 'Brief 1', datePrecision: 'FULL' }, + note: 'Wichtiger Brief' + } + ] + }), + canBlogWrite: false + } + }); + + await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible(); + await expect.element(page.getByText('Dokument öffnen')).toBeVisible(); + await expect.element(page.getByText('Wichtiger Brief')).toBeVisible(); + }); + + it('shows edit/delete actions when canBlogWrite is true', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: true } + }); + + await expect + .element(page.getByRole('link', { name: /bearbeiten/i })) + .toHaveAttribute('href', '/geschichten/g1/edit'); + await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible(); + }); + + it('hides edit/delete actions when canBlogWrite is false', async () => { + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: false } + }); + + await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument(); + }); + + it('clicking delete button calls ondelete prop', async () => { + const ondelete = vi.fn().mockResolvedValue(undefined); + render(StoryReader, { + context: ctx(), + props: { geschichte: baseGeschichte(), canBlogWrite: true, ondelete } + }); + + await userEvent.click(page.getByRole('button', { name: /löschen/i })); + + expect(ondelete).toHaveBeenCalledOnce(); + }); + + it('person chip link meets 44px touch-target minimum height', async () => { + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }] + }), + canBlogWrite: false + } + }); + + const link = document.querySelector('a[href^="/persons/"]'); + const rect = link?.getBoundingClientRect(); + expect(rect?.height).toBeGreaterThanOrEqual(44); + }); + + it('XSS: Story body is sanitised — injected payload does not execute', async () => { + // StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload. + render(StoryReader, { + context: ctx(), + props: { + geschichte: baseGeschichte({ + body: '' + }), + canBlogWrite: false + } + }); + + expect((window as { __xss_story?: number }).__xss_story).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/geschichte/utils.test.ts b/frontend/src/lib/geschichte/utils.test.ts new file mode 100644 index 00000000..e5f78253 --- /dev/null +++ b/frontend/src/lib/geschichte/utils.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { formatAuthorName, formatAuthorDisplayName, formatPublishedAt } from './utils'; + +describe('formatAuthorName', () => { + it('joins firstName and lastName with a space', () => { + expect(formatAuthorName({ firstName: 'Anna', lastName: 'Schmidt', email: 'a@x' })).toBe( + 'Anna Schmidt' + ); + }); + + it('returns firstName alone when lastName is absent', () => { + expect(formatAuthorName({ firstName: 'Anna', email: 'a@x' })).toBe('Anna'); + }); + + it('returns lastName alone when firstName is absent', () => { + expect(formatAuthorName({ lastName: 'Schmidt', email: 'a@x' })).toBe('Schmidt'); + }); + + it('falls back to email when both names are absent', () => { + expect(formatAuthorName({ email: 'fallback@example.com' })).toBe('fallback@example.com'); + }); + + it('returns empty string for null input', () => { + expect(formatAuthorName(null)).toBe(''); + }); + + it('returns empty string for undefined input', () => { + expect(formatAuthorName(undefined)).toBe(''); + }); +}); + +describe('formatAuthorDisplayName', () => { + it('returns displayName when present', () => { + expect(formatAuthorDisplayName({ displayName: 'Anna Schmidt' })).toBe('Anna Schmidt'); + }); + + it('returns empty string for null input', () => { + expect(formatAuthorDisplayName(null)).toBe(''); + }); + + it('returns empty string for undefined input', () => { + expect(formatAuthorDisplayName(undefined)).toBe(''); + }); +}); + +describe('formatPublishedAt', () => { + it('returns null for null input', () => { + expect(formatPublishedAt(null)).toBeNull(); + }); + + it('returns null for undefined input', () => { + expect(formatPublishedAt(undefined)).toBeNull(); + }); + + it('formats an ISO datetime string to a localised date', () => { + const result = formatPublishedAt('2026-04-15T10:00:00Z', 'short'); + expect(result).not.toBeNull(); + expect(result).toContain('2026'); + }); + + it('slices to date-only before formatting (no TZ off-by-one)', () => { + // Both dates should format identically regardless of timezone offset + const a = formatPublishedAt('2026-04-15T00:00:00Z', 'short'); + const b = formatPublishedAt('2026-04-15T23:59:59Z', 'short'); + expect(a).toBe(b); + }); +}); diff --git a/frontend/src/lib/geschichte/utils.ts b/frontend/src/lib/geschichte/utils.ts new file mode 100644 index 00000000..36db9d05 --- /dev/null +++ b/frontend/src/lib/geschichte/utils.ts @@ -0,0 +1,22 @@ +import { formatDate } from '$lib/shared/utils/date'; + +type AuthorSummary = { firstName?: string; lastName?: string; email: string }; +type AuthorView = { displayName: string }; + +export function formatAuthorName(author: AuthorSummary | null | undefined): string { + if (!author) return ''; + const full = [author.firstName, author.lastName].filter(Boolean).join(' ').trim(); + return full || author.email || ''; +} + +export function formatAuthorDisplayName(author: AuthorView | null | undefined): string { + return author?.displayName ?? ''; +} + +export function formatPublishedAt( + publishedAt: string | null | undefined, + style: 'short' | 'long' = 'short' +): string | null { + if (!publishedAt) return null; + return formatDate(publishedAt.slice(0, 10), style); +} diff --git a/frontend/src/lib/shared/actions/radioGroupNav.ts b/frontend/src/lib/shared/actions/radioGroupNav.ts index 65721e3a..6c3d5838 100644 --- a/frontend/src/lib/shared/actions/radioGroupNav.ts +++ b/frontend/src/lib/shared/actions/radioGroupNav.ts @@ -18,9 +18,8 @@ export function radioGroupNav( const delta = event.key === 'ArrowRight' ? 1 : -1; const next = (current + delta + radios.length) % radios.length; - radios[current].setAttribute('aria-checked', 'false'); - radios[next].setAttribute('aria-checked', 'true'); radios[next].focus(); + radios.forEach((r, i) => r.setAttribute('aria-checked', i === next ? 'true' : 'false')); onChangeFn?.(radios[next].getAttribute('value') ?? ''); } diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index 6efec2a7..59a0a846 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -46,6 +46,10 @@ export type ErrorCode = | 'CIRCULAR_RELATIONSHIP' | 'DUPLICATE_RELATIONSHIP' | 'GESCHICHTE_NOT_FOUND' + | 'JOURNEY_ITEM_NOT_FOUND' + | 'JOURNEY_ITEM_POSITION_CONFLICT' + | 'JOURNEY_AT_CAPACITY' + | 'GESCHICHTE_TYPE_MISMATCH' | 'INVALID_CREDENTIALS' | 'SESSION_EXPIRED' | 'MISSING_CREDENTIALS' @@ -164,6 +168,14 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_duplicate_relationship(); case 'GESCHICHTE_NOT_FOUND': return m.error_geschichte_not_found(); + case 'JOURNEY_ITEM_NOT_FOUND': + return m.error_journey_item_not_found(); + case 'JOURNEY_ITEM_POSITION_CONFLICT': + return m.error_journey_item_position_conflict(); + case 'JOURNEY_AT_CAPACITY': + return m.error_journey_at_capacity(); + case 'GESCHICHTE_TYPE_MISMATCH': + return m.error_geschichte_type_mismatch(); case 'INVALID_CREDENTIALS': return m.error_invalid_credentials(); case 'SESSION_EXPIRED': diff --git a/frontend/src/lib/shared/utils/extractText.spec.ts b/frontend/src/lib/shared/utils/extractText.spec.ts index 404ac5cb..89f12985 100644 --- a/frontend/src/lib/shared/utils/extractText.spec.ts +++ b/frontend/src/lib/shared/utils/extractText.spec.ts @@ -48,6 +48,18 @@ describe('extractText', () => { }); }); +// SSR regex-fallback XSS gate — must stay in the Node (.test.ts / .spec.ts) project. +// The browser project's DOMParser would silently take the safe branch → false green. +// This test fires the regex fallback specifically (Node has no DOMParser). +describe('plainExcerpt — SSR regex-fallback XSS gate (Node tier)', () => { + it('does not emit onerror= in output when given an payload (security regression)', () => { + // plainExcerpt calls extractText which regex-strips tags in Node (no DOMParser). + // SvelteKit SSR auto-escapes the result, so onerror= in output is the first-paint risk. + const out = plainExcerpt(''); + expect(out).not.toContain('onerror='); + }); +}); + describe('plainExcerpt', () => { it('returns full text when under the limit', () => { expect(plainExcerpt('

short

', 80)).toBe('short'); diff --git a/frontend/src/routes/geschichten/+page.server.ts b/frontend/src/routes/geschichten/+page.server.ts index 6d802e8e..ce2b33d8 100644 --- a/frontend/src/routes/geschichten/+page.server.ts +++ b/frontend/src/routes/geschichten/+page.server.ts @@ -9,15 +9,13 @@ type Person = components['schemas']['Person']; export const load: PageServerLoad = async ({ url, fetch }) => { const api = createApiClient(fetch); const personIds = url.searchParams.getAll('personId'); - const documentId = url.searchParams.get('documentId') ?? undefined; const [listResult, ...personResults] = await Promise.all([ api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', - personId: personIds.length ? personIds : undefined, - documentId + personId: personIds.length ? personIds : undefined } } }), @@ -34,7 +32,6 @@ export const load: PageServerLoad = async ({ url, fetch }) => { return { geschichten: listResult.data ?? [], - personFilters, - documentFilter: documentId ?? null + personFilters }; }; diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index 7ced4ade..048ceded 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -1,9 +1,8 @@
@@ -132,16 +118,8 @@ function publishedAt(g: { publishedAt?: string }): string | null {
  • - -

    {g.title}

    -

    - {authorName(g)} - {#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if} -

    - {#if g.body} -

    {plainExcerpt(g.body, 150)}

    - {/if} -
    + +
  • {/each} diff --git a/frontend/src/routes/geschichten/[id]/+page.svelte b/frontend/src/routes/geschichten/[id]/+page.svelte index 6a48ec91..b691bc0e 100644 --- a/frontend/src/routes/geschichten/[id]/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/+page.svelte @@ -1,33 +1,34 @@ @@ -50,94 +54,37 @@ async function handleDelete() {
    -

    - {g.title} -

    +
    +

    + {g.title} +

    + {#if isJourney} + + {m.journey_badge_detail()} + + {/if} +

    - {authorName()} + {authorName} {#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}

    - -
    - - {@html sanitized} -
    + {#if isJourney} + + {:else} + + {/if}
    - - - {#if g.persons && g.persons.length > 0} -
    -

    - {m.geschichten_persons_section()} -

    - -
    - {/if} - - - {#if g.documents && g.documents.length > 0} -
    -

    - {m.geschichten_documents_section()} -

    - -
    - {/if} - - - {#if data.canBlogWrite} -
    - - {m.btn_edit()} - - -
    - {/if}
    diff --git a/frontend/src/routes/geschichten/[id]/edit/+page.svelte b/frontend/src/routes/geschichten/[id]/edit/+page.svelte index b35f5a35..dede091b 100644 --- a/frontend/src/routes/geschichten/[id]/edit/+page.svelte +++ b/frontend/src/routes/geschichten/[id]/edit/+page.svelte @@ -17,7 +17,6 @@ async function handleSubmit(payload: { body: string; status: 'DRAFT' | 'PUBLISHED'; personIds: string[]; - documentIds: string[]; }) { submitting = true; errorMessage = null; diff --git a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts index 9bc111c2..25d8b4cd 100644 --- a/frontend/src/routes/geschichten/[id]/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/[id]/page.svelte.test.ts @@ -1,25 +1,51 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; -import { page } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ + beforeNavigate: () => {}, + afterNavigate: () => {}, + goto: vi.fn(), + invalidate: vi.fn(), + invalidateAll: vi.fn(), + preloadCode: vi.fn(), + preloadData: vi.fn(), + pushState: vi.fn(), + replaceState: vi.fn(), + disableScrollHandling: vi.fn(), + onNavigate: () => () => {} +})); + +vi.mock('$lib/shared/cookies', () => ({ + csrfFetch: vi.fn() +})); import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js'; +import { csrfFetch } from '$lib/shared/cookies'; +import { goto } from '$app/navigation'; +import type { components } from '$lib/generated/api'; const { default: GeschichtePage } = await import('./+page.svelte'); afterEach(cleanup); +beforeEach(() => { + vi.clearAllMocks(); +}); -const baseGeschichte = (overrides: Record = {}) => ({ +type GeschichteView = components['schemas']['GeschichteView']; + +const baseGeschichte = (overrides: Partial = {}): GeschichteView => ({ id: 'g1', title: 'Die Reise nach Berlin', body: '

    Im Jahr 1923 fuhr Helene...

    ', - publishedAt: '2026-04-15T10:00:00Z' as string | null, - author: { firstName: 'Anna', lastName: 'Schmidt', email: 'anna@example.com' } as { - firstName?: string; - lastName?: string; - email: string; - } | null, - persons: [] as { id: string; displayName: string }[], - documents: [] as { id: string; title: string; documentDate?: string | null }[], + type: 'STORY', + status: 'PUBLISHED', + author: { id: 'u1', displayName: 'Anna Schmidt' }, + persons: [], + items: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + publishedAt: '2026-04-15T10:00:00Z', ...overrides }); @@ -55,9 +81,7 @@ describe('geschichten/[id] page', () => { context: new Map([[CONFIRM_KEY, createConfirmService()]]), props: { data: baseData({ - geschichte: baseGeschichte({ - author: { firstName: undefined, lastName: undefined, email: 'fallback@example.com' } - }) + geschichte: baseGeschichte({ author: { id: 'u2', displayName: 'fallback@example.com' } }) }) } }); @@ -65,10 +89,10 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText(/fallback@example.com/)).toBeVisible(); }); - it('renders an empty author when author is null', async () => { + it('renders an empty author when author is absent', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ author: null }) }) } + props: { data: baseData({ geschichte: baseGeschichte({ author: undefined }) }) } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); @@ -86,7 +110,9 @@ describe('geschichten/[id] page', () => { it('omits the publishedAt suffix when publishedAt is null', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), - props: { data: baseData({ geschichte: baseGeschichte({ publishedAt: null }) }) } + props: { + data: baseData({ geschichte: baseGeschichte({ publishedAt: undefined }) }) + } }); await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument(); @@ -108,8 +134,8 @@ describe('geschichten/[id] page', () => { data: baseData({ geschichte: baseGeschichte({ persons: [ - { id: 'p1', displayName: 'Helene Schmidt' }, - { id: 'p2', displayName: 'Karl Müller' } + { id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }, + { id: 'p2', firstName: 'Karl', lastName: 'Müller' } ] }) }) @@ -130,20 +156,28 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument(); }); - it('renders the documents section when there are linked documents', async () => { + it('renders the documents section when there are linked journey items', async () => { render(GeschichtePage, { context: new Map([[CONFIRM_KEY, createConfirmService()]]), props: { data: baseData({ geschichte: baseGeschichte({ - documents: [{ id: 'd1', title: 'Brief 1923', documentDate: '1923-04-15' }] + items: [ + { + id: 'item1', + position: 0, + document: { id: 'd1', title: 'Brief 1923', datePrecision: 'FULL' }, + note: 'Brief aus 1923' + } + ] }) }) } }); await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible(); - await expect.element(page.getByText('Brief 1923')).toBeVisible(); + await expect.element(page.getByText('Dokument öffnen')).toBeVisible(); + await expect.element(page.getByText('Brief aus 1923')).toBeVisible(); }); it('renders edit and delete actions when canBlogWrite is true', async () => { @@ -167,4 +201,77 @@ describe('geschichten/[id] page', () => { await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument(); await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument(); }); + + it('STORY with items:[] renders rich-text body and no empty-state message', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { data: baseData({ geschichte: baseGeschichte({ type: 'STORY', items: [] }) }) } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); + + it('type:undefined + non-empty body renders StoryReader and no empty-state', async () => { + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, createConfirmService()]]), + props: { + data: baseData({ + geschichte: baseGeschichte({ + type: undefined as unknown as 'STORY' | 'JOURNEY', + items: [] + }) + }) + } + }); + + await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible(); + await expect.element(page.getByText(/Diese Lesereise ist noch leer/)).not.toBeInTheDocument(); + }); + + it('delete success: navigates to /geschichten after confirmed DELETE returns ok', async () => { + vi.mocked(csrfFetch).mockResolvedValue(new Response(null, { status: 200 })); + const confirmService = createConfirmService(); + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, confirmService]]), + props: { data: baseData({ canBlogWrite: true }) } + }); + + // Trigger delete — opens confirm dialog + const deleteBtn = page.getByRole('button', { name: /löschen/i }); + await userEvent.click(deleteBtn); + + // Settle the confirmation dialog + confirmService.settle(true); + + // Wait for the async delete to complete, then check goto was called + await vi.waitFor(() => { + expect(vi.mocked(goto)).toHaveBeenCalledWith('/geschichten'); + }); + }); + + it('delete failure: shows error message when DELETE returns non-ok', async () => { + vi.mocked(csrfFetch).mockResolvedValue( + new Response(JSON.stringify({ code: 'FORBIDDEN' }), { + status: 403, + headers: { 'Content-Type': 'application/json' } + }) + ); + const confirmService = createConfirmService(); + render(GeschichtePage, { + context: new Map([[CONFIRM_KEY, confirmService]]), + props: { data: baseData({ canBlogWrite: true }) } + }); + + // Trigger delete — opens confirm dialog + const deleteBtn = page.getByRole('button', { name: /löschen/i }); + await userEvent.click(deleteBtn); + + // Settle the confirmation dialog + confirmService.settle(true); + + // Wait for the error to appear inline + await expect.element(page.getByRole('alert')).toBeVisible(); + expect(vi.mocked(goto)).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/routes/geschichten/new/+page.server.ts b/frontend/src/routes/geschichten/new/+page.server.ts index 3983d924..652aa86c 100644 --- a/frontend/src/routes/geschichten/new/+page.server.ts +++ b/frontend/src/routes/geschichten/new/+page.server.ts @@ -10,24 +10,21 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => { const api = createApiClient(fetch); const personId = url.searchParams.get('personId'); - const documentId = url.searchParams.get('documentId'); - const [personResult, documentResult] = await Promise.all([ - personId - ? api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) - : Promise.resolve(null), - documentId - ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) - : Promise.resolve(null) - ]); + const personResult = personId + ? await api.GET('/api/persons/{id}', { params: { path: { id: personId } } }) + : null; // Silently ignore 404/403 to avoid leaking entity existence on unknown IDs. const initialPersons = personResult && personResult.response.ok && personResult.data ? [personResult.data] : []; - const initialDocuments = - documentResult && documentResult.response.ok && documentResult.data - ? [documentResult.data] - : []; - return { initialPersons, initialDocuments }; + // Validate ?type against the known union — prevents unexpected strings from reaching the API. + // Security note: strict equality rejects encoded variants (e.g. STORY%00JOURNEY) and + // only the FIRST value is returned by searchParams.get() on repeated params. + const rawType = url.searchParams.get('type'); + const selectedType: 'STORY' | 'JOURNEY' | null = + rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null; + + return { initialPersons, selectedType }; }; diff --git a/frontend/src/routes/geschichten/new/+page.svelte b/frontend/src/routes/geschichten/new/+page.svelte index 70ec081e..0eff0a68 100644 --- a/frontend/src/routes/geschichten/new/+page.svelte +++ b/frontend/src/routes/geschichten/new/+page.svelte @@ -1,43 +1,12 @@
    @@ -47,19 +16,16 @@ async function handleSubmit(payload: {

    {m.geschichten_new_button()}

    - {#if errorMessage} - diff --git a/frontend/src/routes/geschichten/new/StoryCreate.svelte b/frontend/src/routes/geschichten/new/StoryCreate.svelte new file mode 100644 index 00000000..5bd49fdc --- /dev/null +++ b/frontend/src/routes/geschichten/new/StoryCreate.svelte @@ -0,0 +1,50 @@ + + +{#if errorMessage} + +{/if} + + diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte b/frontend/src/routes/geschichten/new/TypeSelector.svelte new file mode 100644 index 00000000..78c955b4 --- /dev/null +++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte @@ -0,0 +1,97 @@ + + +
    +

    + {m.journey_selector_question()} +

    + +
    { + if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType); + }} + > + {#each TYPES as type (type)} + + {/each} +
    + +
    {announcement}
    + + {#if !selected} + + {/if} + + +
    diff --git a/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts new file mode 100644 index 00000000..9aff1ca9 --- /dev/null +++ b/frontend/src/routes/geschichten/new/TypeSelector.svelte.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +const { default: TypeSelector } = await import('./TypeSelector.svelte'); + +afterEach(cleanup); + +describe('TypeSelector', () => { + it('renders both type cards', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + await expect.element(page.getByRole('radio', { name: /Geschichte/i })).toBeVisible(); + await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible(); + }); + + it('radiogroup is correctly labelled', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const group = document.querySelector('[role="radiogroup"]'); + const labelledBy = group?.getAttribute('aria-labelledby'); + const labelEl = labelledBy ? document.getElementById(labelledBy) : null; + expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0); + }); + + it('Weiter button has aria-disabled=true when nothing is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const weiter = document.querySelector('button[type="button"]:not([role="radio"])'); + expect(weiter?.getAttribute('aria-disabled')).toBe('true'); + }); + + it('no card is aria-checked when nothing is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const radios = Array.from(document.querySelectorAll('[role="radio"]')); + const anyChecked = radios.some((r) => r.getAttribute('aria-checked') === 'true'); + expect(anyChecked).toBe(false); + }); + + it('with no selection: first card has tabindex=0, second has tabindex=-1', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const radios = Array.from(document.querySelectorAll('[role="radio"]')); + expect(radios[0]?.getAttribute('tabindex')).toBe('0'); + expect(radios[1]?.getAttribute('tabindex')).toBe('-1'); + }); + + it('clicking STORY card sets aria-checked=true and enables Weiter', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await userEvent.click(storyCard); + + await expect.element(storyCard).toHaveAttribute('aria-checked', 'true'); + const weiter = document.querySelector('button[type="button"]:not([role="radio"])'); + expect(weiter?.getAttribute('aria-disabled')).toBe('false'); + }); + + it('clicking JOURNEY card sets aria-checked=true', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const journeyCard = page.getByRole('radio', { name: /Lesereise/i }); + await userEvent.click(journeyCard); + + await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true'); + }); + + it('clicking Weiter after selection calls onweiter with the selected type', async () => { + const onweiter = vi.fn(); + render(TypeSelector, { props: { onweiter } }); + + await userEvent.click(page.getByRole('radio', { name: /Geschichte/i })); + const weiter = page.getByRole('button', { name: /Weiter/i }); + await userEvent.click(weiter); + + expect(onweiter).toHaveBeenCalledWith('STORY'); + }); + + it('clicking Weiter without selection does NOT call onweiter', async () => { + const onweiter = vi.fn(); + render(TypeSelector, { props: { onweiter } }); + + // aria-disabled="true" prevents Playwright actionability — dispatch via DOM to test handler behaviour + const weiter = document.querySelector('button[aria-disabled="true"]'); + weiter?.click(); + + expect(onweiter).not.toHaveBeenCalled(); + }); + + it('instructional text is visible when no type is selected', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + await expect.element(page.getByText(/Bitte wähle einen Typ/i)).toBeVisible(); + }); + + it('ArrowRight moves focus and selection from STORY to JOURNEY', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator + await userEvent.keyboard('{ArrowRight}'); + + const journeyCard = page.getByRole('radio', { name: /Lesereise/i }); + await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true'); + await expect.element(storyCard).toHaveAttribute('aria-checked', 'false'); + }); + + it('ArrowLeft wraps from STORY back to JOURNEY', async () => { + render(TypeSelector, { props: { onweiter: vi.fn() } }); + + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await userEvent.click(storyCard); // click focuses the card; .focus() is not on vitest-browser Locator + await userEvent.keyboard('{ArrowLeft}'); + + const journeyCard = page.getByRole('radio', { name: /Lesereise/i }); + await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/frontend/src/routes/geschichten/new/page.server.test.ts b/frontend/src/routes/geschichten/new/page.server.test.ts new file mode 100644 index 00000000..ba440ca3 --- /dev/null +++ b/frontend/src/routes/geschichten/new/page.server.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { API_INTERNAL_URL: 'http://backend:8080' } +})); + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(() => ({ + GET: vi.fn().mockResolvedValue({ response: { ok: false }, data: null }) + })) +})); + +import { load } from './+page.server'; + +function makeEvent(search: string, canBlogWrite = true) { + return { + url: new URL(`http://localhost/geschichten/new${search}`), + request: new Request(`http://localhost/geschichten/new${search}`), + fetch: vi.fn(), + parent: vi.fn().mockResolvedValue({ canBlogWrite }) + } as never; +} + +describe('geschichten/new load — selectedType validation (security regression)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns selectedType: STORY for ?type=STORY', async () => { + const result = await load(makeEvent('?type=STORY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns selectedType: JOURNEY for ?type=JOURNEY', async () => { + const result = await load(makeEvent('?type=JOURNEY')); + expect(result.selectedType).toBe('JOURNEY'); + }); + + it('returns selectedType: null when ?type param is absent', async () => { + const result = await load(makeEvent('')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for invalid ?type param (security regression)', async () => { + const result = await load(makeEvent('?type=ADMIN')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: null for ?type=STORY%00JOURNEY (null-byte encoded — strict equality rejects it)', async () => { + // Strict equality rejects encoded variants; .includes/.startsWith would not. + const result = await load(makeEvent('?type=STORY%00JOURNEY')); + expect(result.selectedType).toBeNull(); + }); + + it('returns selectedType: STORY for repeated ?type=STORY&type=JOURNEY (first-value semantics — intentional)', async () => { + // url.searchParams.get() returns the first value; this is intentional and documented. + const result = await load(makeEvent('?type=STORY&type=JOURNEY')); + expect(result.selectedType).toBe('STORY'); + }); + + it('returns BOTH selectedType: STORY AND initialPersons when ?type=STORY&personId=p1 (no coupling)', async () => { + const { createApiClient } = await import('$lib/shared/api.server'); + vi.mocked(createApiClient).mockReturnValue({ + GET: vi + .fn() + .mockResolvedValue({ response: { ok: true }, data: { id: 'p1', displayName: 'Anna' } }) + } as never); + + const result = await load(makeEvent('?type=STORY&personId=p1')); + expect(result.selectedType).toBe('STORY'); + expect(result.initialPersons).toHaveLength(1); + }); + + it('redirects non-BLOG_WRITE users to /geschichten', async () => { + await expect(load(makeEvent('', false))).rejects.toMatchObject({ location: '/geschichten' }); + }); +}); diff --git a/frontend/src/routes/geschichten/new/page.svelte.test.ts b/frontend/src/routes/geschichten/new/page.svelte.test.ts index 7e10a727..26a0ad49 100644 --- a/frontend/src/routes/geschichten/new/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/new/page.svelte.test.ts @@ -20,51 +20,88 @@ const { default: GeschichtenNewPage } = await import('./+page.svelte'); afterEach(cleanup); -const baseData = { +const baseData = (overrides: Record = {}) => ({ initialPersons: [] as { id: string; displayName: string }[], - initialDocuments: [] as { id: string; title: string }[] -}; + selectedType: 'STORY' as 'STORY' | 'JOURNEY' | null, + ...overrides +}); describe('geschichten/new page', () => { it('renders the page heading', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); await expect.element(page.getByRole('heading', { level: 1 })).toBeVisible(); }); it('renders a button (BackButton component)', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); const buttons = document.querySelectorAll('button'); expect(buttons.length).toBeGreaterThan(0); }); it('does not render an error banner by default', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + render(GeschichtenNewPage, { props: { data: baseData() } }); expect(document.querySelector('[role="alert"]')).toBeNull(); }); - it('renders the GeschichteEditor child component', async () => { - render(GeschichtenNewPage, { props: { data: baseData } }); + it('renders the GeschichteEditor when selectedType is STORY', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'STORY' }) } }); // Editor renders inputs/textarea — verify at least one form input is present const inputs = document.querySelectorAll('input, textarea'); expect(inputs.length).toBeGreaterThan(0); }); - it('passes initialPersons and initialDocuments through to the editor', async () => { + it('passes initialPersons through to the editor', async () => { render(GeschichtenNewPage, { props: { - data: { - initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }], - initialDocuments: [{ id: 'd1', title: 'Brief 1923' }] - } + data: baseData({ + selectedType: 'STORY', + initialPersons: [{ id: 'p1', displayName: 'Anna Schmidt' }] + }) } }); - // Both should appear somewhere in the rendered editor await expect.element(page.getByText('Anna Schmidt')).toBeVisible(); - await expect.element(page.getByText('Brief 1923')).toBeVisible(); + }); + + it('shows TypeSelector radiogroup when selectedType is null', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } }); + + await expect.element(page.getByRole('radiogroup')).toBeVisible(); + }); + + it('shows JOURNEY placeholder when selectedType is JOURNEY', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); + + const placeholder = document.querySelector('[data-testid="journey-placeholder"]'); + expect(placeholder).not.toBeNull(); + }); + + it('JOURNEY placeholder offers a return-to-selection link', async () => { + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: 'JOURNEY' }) } }); + + const backLink = page.getByRole('link', { name: /andere Auswahl/i }); + await expect.element(backLink).toBeVisible(); + await expect.element(backLink).toHaveAttribute('href', '/geschichten/new'); + }); + + it('TypeSelector Weiter calls goto with ?type=STORY on STORY selection', async () => { + const { goto } = await import('$app/navigation'); + vi.mocked(goto).mockClear(); + + render(GeschichtenNewPage, { props: { data: baseData({ selectedType: null }) } }); + + // Select STORY + const storyCard = page.getByRole('radio', { name: /Geschichte/i }); + await storyCard.click(); + + // Click Weiter + const weiter = page.getByRole('button', { name: /Weiter/i }); + await weiter.click(); + + expect(goto).toHaveBeenCalledWith('/geschichten/new?type=STORY'); }); }); diff --git a/frontend/src/routes/geschichten/page.svelte.spec.ts b/frontend/src/routes/geschichten/page.svelte.spec.ts index f5f7621e..5f3a411c 100644 --- a/frontend/src/routes/geschichten/page.svelte.spec.ts +++ b/frontend/src/routes/geschichten/page.svelte.spec.ts @@ -91,6 +91,19 @@ describe('geschichten page — multi-person filter chips', () => { window.history.replaceState({}, '', originalHref); }); + it('JOURNEY row in the list shows the REISE badge (integration: page passes type through)', async () => { + render(Page, { + data: makeData({ + geschichten: [ + { id: 'g1', title: 'Lesereise Berlin', type: 'JOURNEY' } + ] as PageData['geschichten'] + }) + }); + + const badge = document.querySelector('[data-testid="journey-badge"]'); + expect(badge).not.toBeNull(); + }); + it('shows the "+ Person wählen" button even when filters are already active', async () => { render(Page, { data: makeData({ diff --git a/frontend/src/routes/geschichten/page.svelte.test.ts b/frontend/src/routes/geschichten/page.svelte.test.ts index d5e78ba0..663859b9 100644 --- a/frontend/src/routes/geschichten/page.svelte.test.ts +++ b/frontend/src/routes/geschichten/page.svelte.test.ts @@ -188,8 +188,9 @@ describe('geschichten/+ page', () => { // No "·" separator before date when no publishedAt const titleHeading = document.querySelector('h2'); const card = titleHeading?.closest('li'); - // The middle paragraph (author line) should not contain "·" expect(card?.textContent).toContain('Anna Schmidt'); + // "·" separator must be absent when there is no publishedAt date + expect(card?.textContent).not.toContain('·'); }); it('omits the body excerpt when body is empty', async () => { diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index 66702879..8990d355 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -77,6 +77,11 @@ --color-warning: #b45309; --color-warning-fg: #ffffff; + /* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */ + --color-journey-tint: var(--c-journey-bg); + --color-journey: var(--c-journey-text); + --color-journey-border: var(--c-journey-border); + /* Static brand tokens (not themed) */ --color-brand-navy: var(--palette-navy); --color-brand-mint: var(--palette-mint); @@ -128,6 +133,12 @@ /* Parchment — warm near-white for example blocks (light mode: cream #FAF8F1) */ --c-parchment: #faf8f1; + /* Journey / Lesereise — orange semantic tokens + Text #7A3F0E on bg #FEF0E6 ≈ 7.4:1 — WCAG AAA ✓ (text-xs requires 4.5:1 normal-text) */ + --c-journey-bg: #fef0e6; + --c-journey-text: #7a3f0e; + --c-journey-border: #f0c99a; + /* Tag color tokens — decorative dot colors on tag chips */ --c-tag-sage: #5a8a6a; --c-tag-sienna: #a0522d; @@ -246,6 +257,12 @@ /* Stammbaum gutter stripe (issue #689) — 14% mint on dark canvas for visibility parity with the 8% light-mode token. Decorative carve-out. */ --c-gutter-stripe: rgba(161, 220, 216, 0.14); + + /* Journey / Lesereise — muted warm tint on dark navy; text #E8862A on + #3A2A1A ≈ 5.2:1 — WCAG AA ✓ (text-xs requires 4.5:1 normal-text) */ + --c-journey-bg: #3a2a1a; + --c-journey-text: #e8862a; + --c-journey-border: #7a4a1e; } } @@ -321,6 +338,11 @@ /* Stammbaum gutter stripe (issue #689) — KEEP IN SYNC with the @media block. */ --c-gutter-stripe: rgba(161, 220, 216, 0.14); + + /* Journey / Lesereise — KEEP IN SYNC with the @media block above */ + --c-journey-bg: #3a2a1a; + --c-journey-text: #e8862a; + --c-journey-border: #7a4a1e; } /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as ──── */