From 0780c09bb4b761ec5724fcbfd211bae195581bb5 Mon Sep 17 00:00:00 2001 From: marcel Date: Mon, 8 Jun 2026 22:15:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(geschichte):=20JourneyItem=20CRUD=20API=20?= =?UTF-8?q?=E2=80=94=20append,=20updateNote,=20delete,=20reorder=20(#751)?= =?UTF-8?q?=20(#788)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements the backend JourneyItem CRUD API on top of the data model from #750, building towards the full Lesereisen feature (#751). **Completed in this PR:** - `jackson-databind-nullable` 0.2.6 + `JacksonConfig` (`@Bean Module`) for three-way PATCH semantics (`JsonNullable`) - `AuditKind`: `JOURNEY_ITEM_ADDED`, `JOURNEY_ITEM_REMOVED`, `JOURNEY_ITEMS_REORDERED` (last is rollup-eligible) - `ErrorCode`: `JOURNEY_ITEM_NOT_FOUND`, `JOURNEY_ITEM_POSITION_CONFLICT` - V73 migration: `UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED` + `CHECK (position > 0)` on `journey_items` - `JourneyItemConstraintsTest`: verifies deferrable flag via `pg_constraint` query; position check; duplicate position rejection (3 passing tests) - Read models: `DocumentSummary`, `JourneyItemView`, `GeschichteView` (with `AuthorView` to prevent AppUser email leak) - `DocumentService.getSummaryById` — lean lookup without tag-color resolution - `JourneyItemRepository`: extended with `findByGeschichteIdOrderByPosition`, `findByIdAndGeschichteId` (IDOR-safe), `findIdsByGeschichteId`, `findMaxPositionByGeschichteId`, `countByGeschichteId` - DTOs: `JourneyItemCreateDTO`, `JourneyItemUpdateDTO` (`JsonNullable note`), `JourneyReorderDTO` **Still in progress (WIP):** - `JourneyItemService` — `append`, `updateNote`, `delete`, `reorder`, `toSummary`, `toView` (Task 6) - `GeschichteService.getById` → returns `GeschichteView` (Task 7) - New endpoints on `GeschichteController` + slice tests (Task 8) - Frontend error codes + i18n + `npm run generate:api` (Task 9) ## Commits - `0b177247` feat(config): add jackson-databind-nullable for JsonNullable PATCH DTO support - `408ae334` feat(audit,error): add JourneyItem AuditKind values and ErrorCodes - `7b06c3ad` feat(migration): V73 adds UNIQUE DEFERRABLE and CHECK position > 0 on journey_items - `160ca1c3` feat(geschichte): add DocumentSummary, JourneyItemView, GeschichteView read models - `2ad5c36e` feat(geschichte): extend JourneyItemRepository and add item DTOs ## Test plan - [ ] `./mvnw test -Dtest=JourneyItemConstraintsTest` — all 3 constraint tests pass - [ ] `./mvnw clean package -DskipTests` — builds clean after remaining tasks are merged - [ ] Frontend: `npm run generate:api` after Task 9 endpoint additions Co-authored-by: Marcel Reviewed-on: https://git.raddatz.cloud/marcel/familienarchiv/pulls/788 --- CLAUDE.md | 3 +- backend/CLAUDE.md | 3 +- .../familienarchiv/audit/AuditKind.java | 19 +- .../document/DocumentService.java | 22 + .../familienarchiv/exception/ErrorCode.java | 8 + .../exception/GlobalExceptionHandler.java | 9 +- .../geschichte/GeschichteController.java | 53 +- .../geschichte/GeschichteQueryService.java | 29 + .../geschichte/GeschichteService.java | 38 +- .../geschichte/GeschichteView.java | 41 ++ .../geschichte/PersonNameFormatter.java | 22 + .../journeyitem/DocumentSummary.java | 23 + .../journeyitem/JourneyItemCreateDTO.java | 12 + .../journeyitem/JourneyItemRepository.java | 30 +- .../journeyitem/JourneyItemService.java | 253 +++++++ .../journeyitem/JourneyItemUpdateDTO.java | 19 + .../journeyitem/JourneyItemView.java | 16 + .../journeyitem/JourneyReorderDTO.java | 12 + ...add_journey_items_position_constraints.sql | 19 + .../geschichte/GeschichteControllerTest.java | 197 ++++- .../GeschichteQueryServiceTest.java | 38 + .../GeschichteServiceIntegrationTest.java | 9 +- .../geschichte/GeschichteServiceTest.java | 123 +++- .../JourneyItemConstraintsTest.java | 99 +++ .../JourneyItemIntegrationTest.java | 87 ++- .../journeyitem/JourneyItemServiceTest.java | 689 ++++++++++++++++++ docs/GLOSSARY.md | 8 +- ...tional-string-three-way-patch-semantics.md | 43 ++ .../c4/l3-backend-3g-supporting.puml | 6 + docs/architecture/db/db-orm.puml | 4 +- frontend/messages/de.json | 5 + frontend/messages/en.json | 5 + frontend/messages/es.json | 5 + frontend/src/lib/generated/api.ts | 261 +++---- frontend/src/lib/shared/errors.ts | 12 + 35 files changed, 2035 insertions(+), 187 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryService.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteView.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/PersonNameFormatter.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/DocumentSummary.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemCreateDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemUpdateDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemView.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyReorderDTO.java create mode 100644 backend/src/main/resources/db/migration/V73__add_journey_items_position_constraints.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteQueryServiceTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemConstraintsTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java create mode 100644 docs/adr/035-optional-string-three-way-patch-semantics.md diff --git a/CLAUDE.md b/CLAUDE.md index 639dd2ba..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 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/GeschichteController.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteController.java index 09be9493..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,8 +1,14 @@ package org.raddatz.familienarchiv.geschichte; import lombok.RequiredArgsConstructor; +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 io.swagger.v3.oas.annotations.Operation; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -10,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; @@ -24,6 +31,7 @@ import java.util.UUID; public class GeschichteController { private final GeschichteService geschichteService; + private final JourneyItemService journeyItemService; @GetMapping public List list( @@ -37,8 +45,8 @@ public class GeschichteController { } @GetMapping("/{id}") - public Geschichte getById(@PathVariable UUID id) { - return geschichteService.getById(id); + public GeschichteView getById(@PathVariable UUID id) { + return geschichteService.getView(id); } @PostMapping @@ -60,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/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index b290186c..2a0d8819 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -2,11 +2,12 @@ package org.raddatz.familienarchiv.geschichte; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.hibernate.Hibernate; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; 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.person.Person; import org.raddatz.familienarchiv.security.Permission; @@ -33,10 +34,9 @@ public class GeschichteService { private final GeschichteRepository geschichteRepository; private final PersonService personService; - // Reserved for lesereisen-editor: JourneyItem document resolution must go through - // DocumentService.getDocumentById to enforce existence and scope checks. private final DocumentService documentService; private final UserService userService; + private final JourneyItemService journeyItemService; /** * Allow-list policy for Geschichte body HTML. Tiptap on the writer side @@ -66,13 +66,37 @@ public class GeschichteService { throw DomainException.notFound( ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id); } - // Force-initialize LAZY items inside this transaction. - // open-in-view is FALSE — without this touch, Jackson serializes a closed - // Hibernate session and throws LazyInitializationException → HTTP 500. - Hibernate.initialize(g.getItems()); 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 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/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 index 5534195b..a1b3baee 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java @@ -1,13 +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 { - List findAllByGeschichteId(UUID geschichteId); + /** 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/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 21fced9d..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,9 +2,11 @@ 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.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.security.SecurityConfig; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.user.CustomUserDetailsService; import org.springframework.beans.factory.annotation.Autowired; @@ -22,6 +24,7 @@ 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; @@ -32,6 +35,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder 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; @@ -44,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 ──────────────────────────────────────────────── @@ -104,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()) @@ -116,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)) @@ -205,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) @@ -233,6 +407,13 @@ class GeschichteControllerTest { .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() { 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 25a46552..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; @@ -105,8 +109,9 @@ class GeschichteServiceIntegrationTest { 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); 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 64920730..d2d5bfed 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -9,6 +9,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; 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.person.Person; import org.raddatz.familienarchiv.security.Permission; @@ -32,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -40,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; @@ -91,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 @@ -103,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 @@ -118,6 +119,104 @@ 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 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 index 8de9c41e..b6c9fcbe 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java @@ -2,6 +2,7 @@ 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; @@ -12,9 +13,15 @@ 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; @@ -40,13 +47,20 @@ class JourneyItemIntegrationTest { @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") @@ -61,6 +75,19 @@ class JourneyItemIntegrationTest { 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 @@ -96,7 +123,7 @@ class JourneyItemIntegrationTest { geschichteRepository.deleteById(geschichteId); em.flush(); - assertThat(journeyItemRepository.findAllByGeschichteId(geschichteId)).isEmpty(); + assertThat(journeyItemRepository.findByGeschichteIdOrderByPosition(geschichteId)).isEmpty(); } @Test @@ -206,4 +233,62 @@ class JourneyItemIntegrationTest { 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/GLOSSARY.md b/docs/GLOSSARY.md index 7dd3419a..e38eb070 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -151,7 +151,13 @@ _See also [Chronik](#chronik-internal)._ **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` (gaps of 1000 leave room for drag-reorder). 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`). +**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. 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 ea08f206..fba5056b 100644 --- a/docs/architecture/c4/l3-backend-3g-supporting.puml +++ b/docs/architecture/c4/l3-backend-3g-supporting.puml @@ -18,6 +18,8 @@ System_Boundary(backend, "API Backend (Spring Boot)") { 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 (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/db/db-orm.puml b/docs/architecture/db/db-orm.puml index 4f71c08a..a1ebd7d2 100644 --- a/docs/architecture/db/db-orm.puml +++ b/docs/architecture/db/db-orm.puml @@ -376,8 +376,10 @@ package "Supporting" { -- geschichte_id : UUID <> document_id : UUID <> - position : INTEGER NOT NULL + position : INTEGER NOT NULL CHECK (position > 0) note : TEXT + == + UNIQUE (geschichte_id, position) DEFERRABLE INITIALLY DEFERRED } } diff --git a/frontend/messages/de.json b/frontend/messages/de.json index da8b0672..928460c3 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", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ab47f6f9..e10ef624 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", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index abe21231..6a8d9eb3 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", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 526e51aa..d5518fd5 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -692,22 +692,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; @@ -875,7 +859,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_1"]; + get: operations["search"]; put?: never; post?: never; delete?: never; @@ -1339,7 +1323,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_2"]; + get: operations["search_1"]; put?: never; post?: never; delete?: never; @@ -1428,6 +1412,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; @@ -1758,7 +1758,6 @@ export interface components { sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; trainingLabels?: ("KURRENT_RECOGNITION" | "KURRENT_SEGMENTATION")[]; - hasTranscription: boolean; thumbnailUrl?: string; }; PersonMention: { @@ -1819,75 +1818,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; @@ -2016,6 +1946,7 @@ export interface components { /** @enum {string} */ status?: "DRAFT" | "PUBLISHED"; personIds?: string[]; + documentIds?: string[]; }; Geschichte: { /** Format: uuid */ @@ -2024,11 +1955,9 @@ export interface components { body?: string; /** @enum {string} */ status: "DRAFT" | "PUBLISHED"; - /** @enum {string} */ - type: "STORY" | "JOURNEY"; author?: components["schemas"]["AppUser"]; persons?: components["schemas"]["Person"][]; - items?: components["schemas"]["JourneyItem"][]; + documents?: components["schemas"]["Document"][]; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -2036,32 +1965,6 @@ export interface components { /** Format: date-time */ publishedAt?: string; }; - JourneyItem: { - /** Format: uuid */ - id: string; - /** Format: int32 */ - position: number; - /** Format: uuid */ - documentId?: string; - note?: string; - }; - GeschichteSummary: { - /** Format: uuid */ - id: string; - title: string; - /** @enum {string} */ - status: "DRAFT" | "PUBLISHED"; - /** @enum {string} */ - type: "STORY" | "JOURNEY"; - author?: { - firstName?: string; - lastName?: string; - email: string; - }; - body?: string; - /** Format: date-time */ - publishedAt?: string; - }; CreateTranscriptionBlockDTO: { /** Format: int32 */ pageNumber?: number; @@ -2300,6 +2203,11 @@ export interface components { /** Format: int64 */ transcriptionCount: number; }; + ActivityActorDTO: { + initials: string; + color: string; + name?: string; + }; TranscriptionQueueItemDTO: { /** Format: uuid */ id: string; @@ -2322,11 +2230,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 @@ -2467,6 +2370,8 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; + first?: boolean; + last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -2475,8 +2380,6 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; - first?: boolean; - last?: boolean; empty?: boolean; }; PageableObject: { @@ -2540,6 +2443,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; @@ -3603,6 +3563,7 @@ export interface operations { query?: { status?: "DRAFT" | "PUBLISHED"; personId?: string[]; + documentId?: string; limit?: number; }; header?: never; @@ -3617,7 +3578,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["GeschichteSummary"][]; + "*/*": components["schemas"]["Geschichte"][]; }; }; }; @@ -4144,26 +4105,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; @@ -4512,7 +4453,7 @@ export interface operations { }; }; }; - search_1: { + search: { parameters: { query?: { q?: string; @@ -5136,7 +5077,7 @@ export interface operations { }; }; }; - search_2: { + search_1: { parameters: { query?: { q?: string; @@ -5306,6 +5247,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; 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':